Files
Odoo-Modules/fusion_rental/models/sale_order.py
gsinghpal 92369be6e0 changes
2026-03-20 11:46:41 -04:00

1999 lines
75 KiB
Python

import base64
import logging
import uuid
from datetime import timedelta
from zoneinfo import ZoneInfo
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
COMPANY_TZ = 'America/Toronto'
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def write(self, vals):
res = super().write(vals)
if 'order_line' in vals:
for order in self:
if order.is_rental_order:
order.invalidate_recordset(['order_line'])
order.order_line._ensure_deposit_lines()
return res
# -----------------------------------------------------------------
# Renewal fields (v1)
# -----------------------------------------------------------------
rental_auto_renew = fields.Boolean(
string="Auto-Renew",
default=True,
tracking=True,
help="Automatically renew this rental when the return date is reached.",
)
rental_auto_renew_off_reason = fields.Text(
string="Auto-Renew Disabled Reason",
tracking=True,
help="Reason for disabling automatic renewal on this order.",
)
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_apply_cc_fee = fields.Boolean(
string="Apply CC Processing Fee",
default=True,
help="When enabled, a credit card processing fee is added to "
"invoices charged via stored card during auto-renewal.",
)
rental_original_duration = fields.Integer(
string="Original Duration (Days)",
compute='_compute_rental_original_duration',
store=True,
help="Current rental period length in days, used for reminder/marketing calculations.",
)
rental_renewal_period = fields.Selection(
[
('daily', 'Daily'),
('weekly', 'Weekly'),
('monthly', 'Monthly'),
('yearly', 'Yearly'),
],
string="Renewal Period",
default='monthly',
help="How often the rental auto-renews. Monthly uses calendar months "
"so the renewal date stays on the same day each month.",
)
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,
)
rental_billing_address = fields.Char(
string="Billing Address",
copy=False,
help="Billing address provided during agreement signing.",
)
rental_billing_postal_code = fields.Char(
string="Billing Postal Code",
copy=False,
help="Billing postal/zip code for card verification (AVS).",
)
rental_agreement_document = fields.Binary(
string="Rental Agreement PDF",
copy=False,
attachment=True,
)
rental_agreement_document_filename = fields.Char(
string="Agreement Filename",
copy=False,
)
# -----------------------------------------------------------------
# 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,
)
# -----------------------------------------------------------------
# Smart-button count fields
# -----------------------------------------------------------------
rental_deposit_invoice_count = fields.Integer(
compute='_compute_rental_invoice_counts',
)
rental_charges_invoice_count = fields.Integer(
compute='_compute_rental_invoice_counts',
)
rental_renewal_invoice_count = fields.Integer(
compute='_compute_rental_invoice_counts',
)
rental_refund_invoice_count = fields.Integer(
compute='_compute_rental_invoice_counts',
)
@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
# -----------------------------------------------------------------
# Timezone helpers -- all customer-facing dates use company TZ
# -----------------------------------------------------------------
def _get_tz(self):
"""Return the ZoneInfo for the company timezone."""
tz_name = (
self.company_id.partner_id.tz
or self.env.user.tz
or COMPANY_TZ
)
try:
return ZoneInfo(tz_name)
except Exception:
return ZoneInfo(COMPANY_TZ)
def _to_local_dt(self, utc_dt):
"""Convert a UTC datetime to the company local timezone."""
if not utc_dt:
return None
return utc_dt.replace(tzinfo=ZoneInfo('UTC')).astimezone(self._get_tz())
def _format_local_dt(self, utc_dt, fmt='%B %d, %Y'):
"""Format a UTC datetime in the company timezone."""
local = self._to_local_dt(utc_dt)
return local.strftime(fmt) if local else ''
def _format_local_dt_with_time(self, utc_dt):
"""Format a UTC datetime with time in the company timezone."""
return self._format_local_dt(utc_dt, '%B %d, %Y at %I:%M %p %Z')
def _local_date_today(self):
"""Return today's date in the company timezone (not UTC)."""
from datetime import datetime, timezone
now_utc = datetime.now(timezone.utc)
return now_utc.astimezone(self._get_tz()).date()
@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
@api.depends(
'rental_deposit_invoice_id',
'rental_charges_invoice_id',
'rental_renewal_log_ids.invoice_id',
'invoice_ids',
)
def _compute_rental_invoice_counts(self):
for order in self:
order.rental_deposit_invoice_count = 1 if order.rental_deposit_invoice_id else 0
order.rental_charges_invoice_count = 1 if order.rental_charges_invoice_id else 0
order.rental_renewal_invoice_count = len(
order.rental_renewal_log_ids.filtered(lambda r: r.invoice_id).mapped('invoice_id')
)
deposit_inv_id = order.rental_deposit_invoice_id.id if order.rental_deposit_invoice_id else 0
order.rental_refund_invoice_count = len(
order.invoice_ids.filtered(
lambda inv: inv.move_type == 'out_refund'
and inv.reversed_entry_id.id == deposit_inv_id
)
) if deposit_inv_id else 0
# -----------------------------------------------------------------
# Smart-button actions
# -----------------------------------------------------------------
def action_view_deposit_invoice(self):
self.ensure_one()
if not self.rental_deposit_invoice_id:
return
return {
'type': 'ir.actions.act_window',
'name': _("Security Deposit"),
'res_model': 'account.move',
'res_id': self.rental_deposit_invoice_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_rental_charges_invoice(self):
self.ensure_one()
if not self.rental_charges_invoice_id:
return
return {
'type': 'ir.actions.act_window',
'name': _("Rental Charges"),
'res_model': 'account.move',
'res_id': self.rental_charges_invoice_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_renewal_invoices(self):
self.ensure_one()
invoices = self.rental_renewal_log_ids.filtered(
lambda r: r.invoice_id
).mapped('invoice_id')
if not invoices:
return
if len(invoices) == 1:
return {
'type': 'ir.actions.act_window',
'name': _("Renewal Invoice"),
'res_model': 'account.move',
'res_id': invoices.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': _("Renewal Invoices"),
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', invoices.ids)],
'target': 'current',
}
def action_view_refund_invoices(self):
self.ensure_one()
if not self.rental_deposit_invoice_id:
return
refunds = self.invoice_ids.filtered(
lambda inv: inv.move_type == 'out_refund'
and inv.reversed_entry_id == self.rental_deposit_invoice_id
)
if not refunds:
return
if len(refunds) == 1:
return {
'type': 'ir.actions.act_window',
'name': _("Refund"),
'res_model': 'account.move',
'res_id': refunds.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': _("Refund Invoices"),
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', refunds.ids)],
'target': 'current',
}
# -----------------------------------------------------------------
# Return override -- intercept with inspection wizard
# -----------------------------------------------------------------
def _has_completed_delivery(self):
"""Return True if the product has been delivered.
Checks both stock pickings (warehouse flow) and qty_delivered on
rental lines (direct delivery without pickings).
"""
has_picking = bool(self.picking_ids.filtered(
lambda p: p.state == 'done' and p.picking_type_code == 'outgoing'
))
if has_picking:
return True
return any(
line.qty_delivered > 0
for line in self.order_line
if line.is_rental
)
def action_open_return(self):
"""Override to show the inspection wizard before processing returns.
If inspection has already been completed for this order (e.g. via a
technician task), fall through to the standard return flow.
Blocks returns entirely when no delivery has been completed.
"""
self.ensure_one()
if not self._has_completed_delivery():
raise UserError(_(
"Cannot return items: no delivery has been completed for "
"this order. Please process the delivery first."
))
if self.rental_inspection_status:
return super().action_open_return()
return {
'name': _("Return & Inspection"),
'type': 'ir.actions.act_window',
'res_model': 'rental.return.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_order_id': self.id,
},
}
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 current rental period length in days.
Used for percentage-based calculations (reminders, marketing)
and short-term detection. NOT used for computing the next
renewal date -- see ``_get_renewal_delta`` for that.
"""
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_renewal_delta(self):
"""Return the relativedelta/timedelta to add for the next renewal.
Monthly and yearly periods use ``relativedelta`` so the renewal
date always lands on the same calendar day (e.g. the 13th).
Daily and weekly periods use a fixed ``timedelta``.
"""
self.ensure_one()
period = self.rental_renewal_period or 'monthly'
if period == 'monthly':
return relativedelta(months=1)
if period == 'yearly':
return relativedelta(years=1)
if period == 'weekly':
return timedelta(weeks=1)
return timedelta(days=max(self._get_rental_duration_days(), 1))
def _get_marketing_target_date(self):
"""When to send the purchase marketing email (percentage of period after start)."""
self.ensure_one()
pct = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_rental.marketing_email_pct', '23'))
duration = self._get_rental_duration_days()
offset_days = max(round(duration * pct / 100), 1)
start = self.rental_start_date
if not start:
return False
return (start + timedelta(days=offset_days)).date()
def _get_reminder_target_date(self):
"""When to send the renewal reminder (date component only).
For standard rentals: percentage-based offset before renewal date.
For short-term rentals: the return date (actual send time is
controlled by _short_term_reminder_ready).
"""
self.ensure_one()
if not self.rental_next_renewal_date:
return False
if self._is_short_term_rental():
if self.rental_return_date:
return self._to_local_dt(self.rental_return_date).date()
return self.rental_next_renewal_date
pct = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_rental.renewal_reminder_pct', '10'))
duration = self._get_rental_duration_days()
offset_days = max(round(duration * pct / 100), 1)
return self.rental_next_renewal_date - timedelta(days=offset_days)
def _short_term_reminder_ready(self):
"""For short-term rentals, True when it's time to send the reminder.
Fires N hours before the scheduled return time so the customer
can bring the item back before renewal kicks in.
"""
self.ensure_one()
if not self.rental_return_date:
return False
hours_before = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_rental.short_term_reminder_hours', '2'))
reminder_time = self.rental_return_date - timedelta(hours=hours_before)
return fields.Datetime.now() >= reminder_time
def _is_short_term_rental(self):
"""True if rental duration is below the short-term threshold."""
self.ensure_one()
threshold = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_rental.short_term_threshold_days', '3'))
return self._get_rental_duration_days() < threshold
def _short_term_grace_expired(self):
"""For short-term rentals, True if the return time + grace has passed.
Compares current time against the scheduled return datetime plus
the configurable grace period (in hours). Both sides use UTC
internally so the comparison is correct regardless of timezone.
"""
self.ensure_one()
grace_hours = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_rental.short_term_grace_hours', '1'))
if not self.rental_return_date:
return False
grace_deadline = self.rental_return_date + timedelta(hours=grace_hours)
now = fields.Datetime.now()
_logger.debug(
"Short-term grace check for %s: now=%s, return=%s, "
"grace=%dh, deadline=%s, expired=%s",
self.name, now, self.rental_return_date,
grace_hours, grace_deadline, now >= grace_deadline,
)
return now >= grace_deadline
def _get_rental_only_lines(self):
"""Return order lines that should be invoiced on renewal.
Excludes security deposits, delivery/installation, fully returned
items, 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
and l.qty_returned < l.product_uom_qty
)
)
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()
rental_lines = self._get_rental_only_lines()
if not rental_lines:
_logger.warning(
"Skipping auto-renewal for %s: no rental lines to invoice.",
self.name,
)
return
old_start = self.rental_start_date
old_return = self.rental_return_date
new_start = old_return
new_return = new_start + self._get_renewal_delta()
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,
})
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 Exception 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 a single renewal email with invoice PDF + Poynt receipt attached."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_renewed',
raise_if_not_found=False,
)
if not template:
_logger.error("Renewal confirmation template not found for %s", self.name)
return
attachment_ids = []
invoice = self.env['account.move'].browse(
renewal_log.invoice_id.id
) if renewal_log and renewal_log.invoice_id else None
if invoice and payment_ok:
attachment_ids = self._generate_invoice_attachments(
invoice, 'Renewal',
)
receipt_ids = self._find_poynt_receipt_attachments(invoice)
attachment_ids.extend(receipt_ids)
try:
template.with_context(
payment_ok=payment_ok,
renewal_log=renewal_log,
renewal_invoice=invoice,
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.info("Renewal confirmation email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send renewal confirmation email for %s: %s", self.name, e)
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 not template:
return
attachment_ids = []
if invoice:
attachment_ids = self._generate_invoice_attachments(
invoice, 'Payment Receipt',
)
template.with_context(
invoice=invoice,
transaction=transaction,
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
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._format_local_dt(self.rental_return_date, '%m/%d/%Y'),
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 renewal reminders based on percentage of rental period."""
today = self._local_date_today()
orders = self.search([
('is_rental_order', '=', True),
('rental_auto_renew', '=', True),
('rental_status', 'in', ('pickup', 'return')),
('rental_next_renewal_date', '>', today),
('rental_reminder_sent', '=', False),
])
for order in orders:
if order._has_pending_cancellation():
continue
try:
target = order._get_reminder_target_date()
if not target or today < target:
continue
if order._is_short_term_rental():
if not order._short_term_reminder_ready():
continue
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 (target date %s)",
order.name, target,
)
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 = self._local_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
if order._is_short_term_rental():
returned = any(
line.qty_returned > 0
for line in order.order_line
if line.is_rental and not line.display_type
)
if returned:
_logger.info(
"Skipping auto-renewal for %s: short-term rental already returned.",
order.name,
)
continue
if not order._short_term_grace_expired():
_logger.info(
"Skipping auto-renewal for %s: short-term grace period not expired.",
order.name,
)
continue
try:
with self.env.cr.savepoint():
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()
new_start = self.rental_return_date
new_return = new_start + self._get_renewal_delta() 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,
'default_payment_token_id': self.rental_payment_token_id.id or False,
'default_previous_start_date': self.rental_start_date,
'default_previous_return_date': self.rental_return_date,
},
}
# =================================================================
# Auto-set sale type for rental orders
# =================================================================
@api.onchange('is_rental_order')
def _onchange_is_rental_order_sale_type(self):
if self.is_rental_order and not self.x_fc_sale_type:
self.x_fc_sale_type = 'rental'
self.x_fc_authorizer_required = 'no'
@api.model_create_multi
def create(self, vals_list):
in_rental = self.env.context.get('in_rental_app')
for vals in vals_list:
is_rental = vals.get('is_rental_order', in_rental)
if is_rental and not vals.get('x_fc_sale_type'):
vals['x_fc_sale_type'] = 'rental'
vals.setdefault('x_fc_authorizer_required', 'no')
return super().create(vals_list)
# =================================================================
# Order Confirmation - auto-create deposit invoice
# =================================================================
def action_confirm(self):
"""Override to create invoices and auto-send agreement on confirmation.
1. Rental charges + delivery/services (everything except deposit)
2. Security deposit (separate invoice)
3. Automatically send the rental agreement for signing
"""
for order in self:
if order.is_rental_order and not order.x_fc_sale_type:
order.x_fc_sale_type = 'rental'
order.x_fc_authorizer_required = 'no'
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,
)
try:
if not order.rental_agreement_signed:
order.action_send_rental_agreement()
except Exception as e:
_logger.error(
"Failed to auto-send rental agreement 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:
try:
template.send_mail(self.id, force_send=True)
_logger.warning("Rental agreement email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send agreement email for %s: %s", self.name, e)
else:
_logger.error("Agreement email template not found for %s", self.name)
self.message_post(body=_("Rental agreement sent to customer for signing."))
def action_send_card_reauthorization(self):
"""Send a card reauthorization form link to the customer."""
self.ensure_one()
self.rental_agreement_token = uuid.uuid4().hex
template = self.env.ref(
'fusion_rental.mail_template_rental_card_reauthorization',
raise_if_not_found=False,
)
if template:
template.send_mail(self.id, force_send=True)
self.message_post(body=_(
"Card reauthorization form sent to customer."
))
def action_open_card_authorization(self):
"""Open the card authorization form in the browser for phone authorization."""
self.ensure_one()
self.rental_agreement_token = uuid.uuid4().hex
base_url = self.env['ir.config_parameter'].sudo().get_param(
'web.base.url', '',
)
url = (
f"{base_url}/rental/reauthorize/"
f"{self.id}/{self.rental_agreement_token}"
)
return {
'type': 'ir.actions.act_url',
'url': url,
'target': 'new',
}
def _process_post_signing_payments(self):
"""Auto-collect payments after the customer signs the agreement.
Posts both invoices, collects payment via token, marks deposit
as collected, emails invoices with receipts, and logs status.
"""
self.ensure_one()
if not self.rental_payment_token_id:
self._notify_staff_manual_payment(
self.rental_charges_invoice_id or self.rental_deposit_invoice_id
)
return
charges_inv = self.rental_charges_invoice_id
deposit_inv = self.rental_deposit_invoice_id
if not charges_inv or not deposit_inv:
try:
if not charges_inv:
self._create_rental_charges_invoice()
charges_inv = self.rental_charges_invoice_id
if not deposit_inv:
deposit_lines = self.order_line.filtered(
lambda l: l.is_security_deposit
)
if deposit_lines:
self._create_deposit_invoice()
deposit_inv = self.rental_deposit_invoice_id
except Exception as e:
_logger.error(
"Invoice creation during post-signing for %s: %s",
self.name, e,
)
for inv in (charges_inv, deposit_inv):
if inv and inv.state == 'cancel':
inv.button_draft()
if charges_inv and charges_inv.state == 'draft':
charges_inv.action_post()
if charges_inv and charges_inv.state == 'posted' and charges_inv.amount_residual > 0:
ok = self._collect_token_payment_for_invoice(charges_inv)
if ok:
self._send_invoice_with_receipt(charges_inv, 'rental_charges')
else:
self._notify_staff_manual_payment(charges_inv)
if deposit_inv and deposit_inv.state == 'cancel':
deposit_inv.button_draft()
if deposit_inv and deposit_inv.state == 'draft':
deposit_inv.action_post()
if deposit_inv and deposit_inv.state == 'posted' and deposit_inv.amount_residual > 0:
ok = self._collect_token_payment_for_invoice(deposit_inv)
if ok:
self.rental_deposit_status = 'collected'
self._send_invoice_with_receipt(deposit_inv, 'security_deposit')
else:
self._notify_staff_manual_payment(deposit_inv)
self.message_post(body=_(
"Agreement signed and payments processed. Order ready for delivery."
))
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 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 _has_items_returned(self):
"""Return True if at least one rental line has qty_returned > 0."""
return any(
line.qty_returned > 0
for line in self.order_line
if line.is_rental
)
def _refund_security_deposit(self):
"""Initiate security deposit refund with configurable hold period.
Validates that the product was actually delivered and returned
before scheduling the refund.
"""
self.ensure_one()
if self.rental_deposit_status != 'collected':
return
if not self.rental_inspection_status:
_logger.warning(
"Skipping deposit refund for %s: inspection not completed.",
self.name,
)
return
if not self._has_completed_delivery():
_logger.warning(
"Skipping deposit refund for %s: no delivery completed.",
self.name,
)
return
if not self._has_items_returned():
_logger.warning(
"Skipping deposit refund for %s: no items returned.",
self.name,
)
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,
))
self._send_deposit_refund_initiated_email()
def _process_deposit_refund(self):
"""Process the actual deposit refund via the deposit wizard, send receipt, and auto-close.
Performs a final safety check to ensure the product was delivered
and returned before issuing the refund.
"""
self.ensure_one()
if not self._has_completed_delivery():
_logger.warning(
"Deposit refund aborted for %s: no delivery completed.",
self.name,
)
return
if not self._has_items_returned():
_logger.warning(
"Deposit refund aborted for %s: no items returned.",
self.name,
)
return
deposit_total = 0.0
if self.rental_deposit_invoice_id:
deposit_total = self.rental_deposit_invoice_id.amount_total
wizard = self.env['deposit.deduction.wizard'].create({
'order_id': self.id,
'deposit_total': deposit_total,
'action_type': 'full_refund',
})
wizard.action_confirm()
def _deduct_security_deposit(self, deduction_amount, reason=''):
"""Deduct from security deposit via the deposit wizard."""
self.ensure_one()
deposit_total = 0.0
if self.rental_deposit_invoice_id:
deposit_total = self.rental_deposit_invoice_id.amount_total
action_type = 'no_refund' if deduction_amount >= deposit_total else 'partial_refund'
wizard = self.env['deposit.deduction.wizard'].create({
'order_id': self.id,
'deposit_total': deposit_total,
'action_type': action_type,
'deduction_amount': deduction_amount,
'reason': reason or _("Damage deduction"),
})
wizard.action_confirm()
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_process_deposit(self):
"""Open the deposit processing wizard."""
self.ensure_one()
if not self.rental_inspection_status:
raise UserError(_(
"Return inspection must be completed before processing the "
"security deposit. Please use the 'Return' button to inspect "
"the items first."
))
if self.rental_deposit_status not in ('collected', 'refund_hold'):
raise UserError(_(
"Deposit must be in 'Collected' or 'Refund Hold' status to process."
))
deposit_total = 0.0
if self.rental_deposit_invoice_id:
deposit_total = self.rental_deposit_invoice_id.amount_total
return {
'name': _("Process 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 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_deduct_deposit(self):
"""Alias: redirect to the unified deposit processing wizard."""
return self.action_process_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 _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
token = self.rental_payment_token_id
PaymentMethod = self.env['payment.method'].sudo().with_context(
active_test=False,
)
payment_method = token.payment_method_id
if not payment_method:
payment_method = PaymentMethod.search(
[('code', '=', 'card')], limit=1,
)
if not payment_method:
payment_method = PaymentMethod.search(
[('code', 'in', ('visa', 'mastercard'))], limit=1,
)
if not payment_method:
_logger.error("No payment method found for token payment on %s", self.name)
return False
reference = self.env['payment.transaction']._compute_reference(
provider.code,
prefix=f"{self.name}-{invoice.name or 'INV'}",
)
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,
'reference': reference,
'operation': 'offline',
'token_id': token.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_initiated_email(self):
"""Send email notifying customer that deposit refund is being processed."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_deposit_refund_initiated',
raise_if_not_found=False,
)
if not template:
_logger.error("Deposit refund initiated template not found for %s", self.name)
return
attachment_ids = []
credit_note = self._find_deposit_credit_note()
if credit_note:
attachment_ids = self._generate_invoice_attachments(
credit_note, 'Deposit Credit Note',
)
try:
template.send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.warning("Deposit refund initiated email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send deposit refund initiated email for %s: %s", self.name, e)
def _send_deposit_refund_email(self):
"""Send the security deposit refund completion email with credit note and receipt."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_deposit_refund',
raise_if_not_found=False,
)
if not template:
_logger.error("Deposit refund template not found for %s", self.name)
return
attachment_ids = []
credit_note = self._find_deposit_credit_note()
if credit_note:
attachment_ids = self._generate_invoice_attachments(
credit_note, 'Deposit Refund',
)
receipt_ids = self._find_poynt_receipt_attachments(credit_note)
attachment_ids.extend(receipt_ids)
try:
template.send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.warning("Deposit refund email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send deposit refund email for %s: %s", self.name, e)
def _send_invoice_with_receipt(self, invoice, invoice_type=''):
"""Send invoice email with the invoice PDF and payment receipt attached.
:param invoice: The paid account.move record.
:param invoice_type: Label hint ('rental_charges', 'security_deposit',
'renewal', 'damage') for context in the email.
"""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_invoice_receipt',
raise_if_not_found=False,
)
if not template:
_logger.error("Invoice receipt email template not found for %s", self.name)
return
type_label = {
'rental_charges': 'Rental Charges',
'security_deposit': 'Security Deposit',
'renewal': 'Renewal',
'damage': 'Damage Assessment',
}.get(invoice_type, 'Invoice')
attachment_ids = self._generate_invoice_attachments(invoice, type_label)
receipt_ids = self._find_poynt_receipt_attachments(invoice)
attachment_ids.extend(receipt_ids)
try:
template.with_context(
rental_invoice=invoice,
rental_invoice_type=invoice_type,
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.warning(
"Invoice receipt email sent for %s (%s) with %d attachments",
self.name, type_label, len(attachment_ids),
)
except Exception as e:
_logger.error(
"Failed to send invoice receipt email for %s (%s): %s",
self.name, type_label, e,
)
# =================================================================
# Email Attachment Helpers
# =================================================================
def _generate_invoice_attachments(self, invoice, type_label='Invoice'):
"""Render an invoice/credit note PDF and return attachment IDs.
:param invoice: The account.move record.
:param type_label: Human-readable label for the filename.
:returns: List of ir.attachment IDs.
"""
self.ensure_one()
attachment_ids = []
try:
inv_report = self.env['ir.actions.report']._get_report_from_name(
'account.report_invoice'
)
if not inv_report:
inv_report = self.env['ir.actions.report']._get_report_from_name(
'account.report_invoice_with_payments'
)
if inv_report:
pdf_content, _ = inv_report._render_qweb_pdf(
inv_report.report_name, [invoice.id]
)
name_parts = (self.partner_id.name or 'Client').strip().split()
first = name_parts[0] if name_parts else 'Client'
last = name_parts[-1] if len(name_parts) > 1 else ''
inv_filename = f"{first}_{last}_{type_label}_{invoice.name or 'Draft'}.pdf"
inv_attach = self.env['ir.attachment'].create({
'name': inv_filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
attachment_ids.append(inv_attach.id)
except Exception as e:
_logger.warning(
"Could not generate invoice PDF for %s: %s",
self.name, e,
)
return attachment_ids
def _find_poynt_receipt_attachments(self, invoice):
"""Find Poynt receipt PDFs attached to an invoice's chatter.
:param invoice: The account.move record.
:returns: List of ir.attachment IDs.
"""
if not invoice:
return []
receipts = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'account.move'),
('res_id', '=', invoice.id),
('name', 'like', 'Payment_Receipt_%'),
('mimetype', '=', 'application/pdf'),
], order='id desc', limit=1)
return receipts.ids
def _find_deposit_credit_note(self):
"""Find the credit note for the deposit invoice."""
self.ensure_one()
if not self.rental_deposit_invoice_id:
return None
credit_note = self.env['account.move'].search([
('reversed_entry_id', '=', self.rental_deposit_invoice_id.id),
('move_type', '=', 'out_refund'),
('state', '=', 'posted'),
], order='id desc', limit=1)
return credit_note or None
def _generate_agreement_attachment_ids(self):
"""Return attachment IDs for the signed rental agreement if present."""
self.ensure_one()
if not self.rental_agreement_document:
return []
existing = self.env['ir.attachment'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('name', 'like', '%Rental Agreement%Signed%'),
('mimetype', '=', 'application/pdf'),
], order='id desc', limit=1)
return existing.ids
def _send_damage_notification_email(self):
"""Notify customer that technician flagged damage on pickup."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_damage_notification',
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 hold period.
Re-validates each order before processing to ensure the product
was actually delivered, returned, and inspected.
"""
today = self._local_date_today()
orders = self.search([
('is_rental_order', '=', True),
('rental_deposit_status', '=', 'refund_hold'),
('rental_deposit_refund_date', '<=', today),
])
for order in orders:
if not order.rental_inspection_status:
_logger.error(
"Deposit refund skipped for %s: inspection not completed.",
order.name,
)
continue
if not order._has_completed_delivery():
_logger.error(
"Deposit refund skipped for %s: no delivery completed.",
order.name,
)
continue
if not order._has_items_returned():
_logger.error(
"Deposit refund skipped for %s: no items returned.",
order.name,
)
continue
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 purchase marketing email based on percentage of rental period."""
today = self._local_date_today()
orders = self.search([
('is_rental_order', '=', True),
('state', '=', 'sale'),
('rental_status', 'in', ('pickup', 'return')),
('rental_marketing_email_sent', '=', False),
('rental_start_date', '!=', False),
])
for order in orders:
try:
target = order._get_marketing_target_date()
if not target or today < target:
continue
order._generate_purchase_coupon()
order._send_marketing_email()
order.rental_marketing_email_sent = True
_logger.info("Sent marketing email for %s (target date %s)", order.name, target)
except Exception as e:
_logger.error("Marketing email failed for %s: %s", order.name, e)
# =================================================================
# Agreement Document
# =================================================================
def _generate_and_attach_signed_agreement(self):
"""Generate signed agreement PDF, store it, attach to chatter, and email to customer."""
self.ensure_one()
report_name = 'fusion_rental.report_rental_agreement'
report = self.env['ir.actions.report']._get_report_from_name(report_name)
if not report:
return
pdf_content, _report_type = report._render_qweb_pdf(report_name, [self.id])
pdf_b64 = base64.b64encode(pdf_content)
partner_name = self.partner_id.name or ''
filename = f"{self.name} - {partner_name} - Rental Agreement - Signed.pdf"
self.write({
'rental_agreement_document': pdf_b64,
'rental_agreement_document_filename': filename,
})
attachment = self.env['ir.attachment'].create({
'name': filename,
'type': 'binary',
'datas': pdf_b64,
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
self.message_post(
body=_("Signed rental agreement attached."),
attachment_ids=[attachment.id],
)
self._send_signed_agreement_email(attachment)
def _send_signed_agreement_email(self, attachment):
"""Email the signed rental agreement PDF to the customer."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_signed_agreement',
raise_if_not_found=False,
)
if template:
try:
template.send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': [attachment.id]},
)
_logger.warning("Signed agreement email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send signed agreement email for %s: %s", self.name, e)
else:
_logger.error("Signed agreement email template not found for %s", self.name)
def action_preview_rental_agreement(self):
"""Open the rental agreement PDF in a preview dialog."""
self.ensure_one()
if not self.rental_agreement_document:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("No Document"),
'message': _("No signed rental agreement document available."),
'type': 'warning',
'sticky': False,
},
}
attachment = self.env['ir.attachment'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('name', 'like', '%Rental Agreement%Signed%'),
], order='id desc', limit=1)
if not attachment:
attachment = self.env['ir.attachment'].create({
'name': self.rental_agreement_document_filename or f"{self.name} - {self.partner_id.name or ''} - Rental Agreement - Signed.pdf",
'type': 'binary',
'datas': self.rental_agreement_document,
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=false',
'target': 'new',
}
# =================================================================
# Transaction Close
# =================================================================
def action_close_rental(self):
"""Close the rental transaction: delete card token, send thank-you."""
self.ensure_one()
if self.rental_closed:
return
if not self.rental_inspection_status and self.rental_status in (
'return', 'returned',
):
raise UserError(_(
"Return inspection must be completed before closing the rental. "
"Please use the 'Return' button to inspect the items first."
))
for inv in (self.rental_charges_invoice_id, self.rental_deposit_invoice_id):
if inv and inv.amount_residual > 0 and inv.state == 'posted':
_logger.warning(
"Closing rental %s with unpaid invoice %s (residual: %s).",
self.name, inv.name, inv.amount_residual,
)
if self.rental_payment_token_id:
token = self.rental_payment_token_id
self.rental_payment_token_id = False
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 and signed agreement."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_thank_you',
raise_if_not_found=False,
)
if not template:
_logger.error("Thank you email template not found for %s", self.name)
return
attachment_ids = self._generate_agreement_attachment_ids()
try:
template.with_context(
google_review_url=self._get_google_review_url(),
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.warning("Thank you email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send thank you email for %s: %s", self.name, e)