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
1708 lines
64 KiB
Python
1708 lines
64 KiB
Python
import base64
|
|
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=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_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,
|
|
)
|
|
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
|
|
|
|
@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 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.
|
|
"""
|
|
self.ensure_one()
|
|
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 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_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 (percentage of period before renewal)."""
|
|
self.ensure_one()
|
|
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)
|
|
if not self.rental_next_renewal_date:
|
|
return False
|
|
return self.rental_next_renewal_date - timedelta(days=offset_days)
|
|
|
|
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."""
|
|
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)
|
|
return fields.Datetime.now() >= grace_deadline
|
|
|
|
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 payment_ok:
|
|
self._send_invoice_with_receipt(invoice, 'renewal')
|
|
|
|
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 (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 with invoice + receipt attached."""
|
|
self.ensure_one()
|
|
template = self.env.ref(
|
|
'fusion_rental.mail_template_rental_renewed',
|
|
raise_if_not_found=False,
|
|
)
|
|
if not template:
|
|
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',
|
|
)
|
|
|
|
template.with_context(
|
|
payment_ok=payment_ok,
|
|
renewal_log=renewal_log,
|
|
).send_mail(
|
|
self.id,
|
|
force_send=True,
|
|
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
|
|
)
|
|
|
|
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.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 renewal reminders based on percentage of rental period."""
|
|
today = fields.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
|
|
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 = 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
|
|
|
|
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:
|
|
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,
|
|
'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.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
if vals.get('is_rental_order') 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:
|
|
template.send_mail(self.id, force_send=True)
|
|
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 _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 _refund_security_deposit(self):
|
|
"""Initiate security deposit refund with configurable hold period."""
|
|
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
|
|
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."""
|
|
self.ensure_one()
|
|
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:
|
|
return
|
|
|
|
attachment_ids = []
|
|
credit_note = self._find_deposit_credit_note()
|
|
if credit_note:
|
|
attachment_ids = self._generate_invoice_attachments(
|
|
credit_note, 'Deposit Credit Note',
|
|
)
|
|
|
|
template.send_mail(
|
|
self.id,
|
|
force_send=True,
|
|
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
|
|
)
|
|
|
|
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:
|
|
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)
|
|
|
|
template.send_mail(
|
|
self.id,
|
|
force_send=True,
|
|
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
|
|
)
|
|
|
|
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:
|
|
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)
|
|
|
|
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 {},
|
|
)
|
|
|
|
# =================================================================
|
|
# 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]
|
|
)
|
|
inv_filename = (
|
|
f"{self.name} - {type_label} - "
|
|
f"{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 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 purchase marketing email based on percentage of rental period."""
|
|
today = fields.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:
|
|
template.send_mail(
|
|
self.id,
|
|
force_send=True,
|
|
email_values={'attachment_ids': [attachment.id]},
|
|
)
|
|
|
|
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:
|
|
return
|
|
|
|
attachment_ids = self._generate_agreement_attachment_ids()
|
|
|
|
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 {},
|
|
)
|