This commit is contained in:
gsinghpal
2026-03-09 15:21:22 -04:00
parent a3e85a23ef
commit acd3fc455e
243 changed files with 20459 additions and 4197 deletions

View File

@@ -1,12 +1,28 @@
import logging
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from odoo import _, fields, http
from odoo.http import request
from odoo.exceptions import UserError
from odoo.tools.misc import format_datetime as _odoo_format_datetime
COMPANY_TZ = 'America/Toronto'
_logger = logging.getLogger(__name__)
def _fmt_date(env, dt_val, tz=None):
"""Format a datetime for portal page display in the company timezone."""
if not dt_val:
return ''
return _odoo_format_datetime(
env, dt_val,
tz=tz or COMPANY_TZ,
dt_format="MMMM dd, yyyy 'at' hh:mm a",
)
class FusionRentalController(http.Controller):
# =================================================================
@@ -34,7 +50,9 @@ class FusionRentalController(http.Controller):
)
order = cancel_request.order_id
today = fields.Date.today()
tz_name = order.company_id.partner_id.tz or COMPANY_TZ
tz = ZoneInfo(tz_name)
today = datetime.now(timezone.utc).astimezone(tz).date()
if order.rental_next_renewal_date and order.rental_next_renewal_date <= today:
return request.render(
'fusion_rental.cancellation_invalid_page',
@@ -63,6 +81,9 @@ class FusionRentalController(http.Controller):
'order': order,
'partner': cancel_request.partner_id,
'token': token,
'formatted_return_date': _fmt_date(
request.env, order.rental_return_date, tz_name,
),
},
)
@@ -149,6 +170,7 @@ class FusionRentalController(http.Controller):
from odoo.addons.fusion_poynt.utils import clean_application_id
poynt_application_id = clean_application_id(raw_app_id) or raw_app_id
tz_name = order.company_id.partner_id.tz or COMPANY_TZ
return request.render(
'fusion_rental.agreement_signing_page',
{
@@ -159,6 +181,12 @@ class FusionRentalController(http.Controller):
'google_api_key': google_api_key,
'poynt_business_id': poynt_business_id,
'poynt_application_id': poynt_application_id,
'formatted_start_date': _fmt_date(
request.env, order.rental_start_date, tz_name,
),
'formatted_return_date': _fmt_date(
request.env, order.rental_return_date, tz_name,
),
},
)

View File

@@ -13,12 +13,12 @@
</record>
<record id="ir_cron_rental_renewal_reminder" model="ir.cron">
<field name="name">Rental: Renewal Reminders (%-based)</field>
<field name="name">Rental: Renewal Reminders (every 2h)</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_rental_renewal_reminders()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="interval_number">2</field>
<field name="interval_type">hours</field>
<field name="active">True</field>
<field name="priority">10</field>
</record>

View File

@@ -20,12 +20,12 @@
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Rental Renewal Notice</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your rental order <strong style="color:#2d3748;"><t t-out="object.name"/></strong> is scheduled for automatic renewal on <strong style="color:#2d3748;"><t t-out="object.rental_return_date and object.rental_return_date.strftime('%B %d, %Y') or ''"/></strong>.
Your rental order <strong style="color:#2d3748;"><t t-out="object.name"/></strong> is scheduled for automatic renewal on <strong style="color:#2d3748;"><t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Renewal Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Order</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Renewal Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_return_date and object.rental_return_date.strftime('%B %d, %Y') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Renewal Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Renewal Amount</td><td style="padding:10px 14px;color:#D69E2E;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="border-left:3px solid #D69E2E;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
@@ -67,8 +67,8 @@
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">New Rental Period</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">New Start</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_start_date and object.rental_start_date.strftime('%B %d, %Y') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">New Return</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_return_date and object.rental_return_date.strftime('%B %d, %Y') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">New Start</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_start_date and format_datetime(object.rental_start_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">New Return</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Renewal #</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.rental_renewal_count"/></td></tr>
</table>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
@@ -109,7 +109,7 @@
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Payment Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Rental Period</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_start_date and object.rental_start_date.strftime('%B %d, %Y') or ''"/> to <t t-out="object.rental_return_date and object.rental_return_date.strftime('%B %d, %Y') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Rental Period</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_start_date and format_datetime(object.rental_start_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/> to <t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Amount Paid</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="ctx.get('invoice') and ctx['invoice'].amount_total or object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
@@ -183,7 +183,7 @@
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Agreement Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Order</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Rental Period</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_start_date and object.rental_start_date.strftime('%B %d, %Y') or ''"/> to <t t-out="object.rental_return_date and object.rental_return_date.strftime('%B %d, %Y') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Rental Period</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_start_date and format_datetime(object.rental_start_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/> to <t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Total</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="text-align:center;margin:0 0 24px 0;">
@@ -227,8 +227,8 @@
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Agreement Summary</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Order</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Signed By</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_agreement_signer_name or object.partner_id.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Signed On</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_agreement_signed_date and object.rental_agreement_signed_date.strftime('%B %d, %Y at %I:%M %p') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Rental Period</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_start_date and object.rental_start_date.strftime('%B %d, %Y') or ''"/> to <t t-out="object.rental_return_date and object.rental_return_date.strftime('%B %d, %Y') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Signed On</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_agreement_signed_date and format_datetime(object.rental_agreement_signed_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Rental Period</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_start_date and format_datetime(object.rental_start_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/> to <t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Total</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="border-left:3px solid #38A169;padding:12px 16px;margin:0 0 24px 0;background:#f0fff4;">
@@ -603,7 +603,7 @@
<div class="card-header bg-warning text-white"><h3 class="mb-0">Cancel Rental &amp; Request Pickup</h3></div>
<div class="card-body">
<p>Cancellation for <strong t-out="order.name">SO001</strong>.</p>
<p><strong>Customer:</strong> <t t-out="partner.name">Name</t><br/><strong>Period Ends:</strong> <t t-out="order.rental_return_date and order.rental_return_date.strftime('%B %d, %Y') or ''">Date</t></p>
<p><strong>Customer:</strong> <t t-out="partner.name">Name</t><br/><strong>Period Ends:</strong> <t t-out="formatted_return_date">Date</t></p>
<form t-attf-action="/rental/cancel/{{ token }}" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3"><label for="reason" class="form-label">Reason (optional)</label><textarea name="reason" id="reason" class="form-control" rows="3" placeholder="Let us know why..."/></div>
@@ -674,8 +674,8 @@
<h5>Order: <a t-att-href="'/my/orders/%d' % order.id" target="_blank" style="color: #0066a1; text-decoration: underline;"><t t-out="order.name">SO001</t></a></h5>
<p><strong>Customer:</strong> <t t-out="partner.name">Name</t></p>
<p><strong>Rental Period:</strong>
<t t-out="order.rental_start_date and order.rental_start_date.strftime('%B %d, %Y') or ''">Start</t>
to <t t-out="order.rental_return_date and order.rental_return_date.strftime('%B %d, %Y') or ''">End</t>
<t t-out="formatted_start_date">Start</t>
to <t t-out="formatted_return_date">End</t>
</p>
<h5 class="mt-4">Order Summary</h5>
@@ -1001,6 +1001,15 @@
}
})
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.error) {
var srvErr = data.error;
var msg = (srvErr.data &amp;&amp; srvErr.data.message) || srvErr.message || 'A server error occurred. Please try again.';
errDiv.textContent = msg;
errDiv.classList.remove('d-none');
btn.disabled = false;
btn.textContent = 'Sign Agreement \x26 Authorize Card';
return;
}
var res = data.result || data;
if (res.success) {
successDiv.textContent = (res.message || 'Signed successfully!') + ' Redirecting...';
@@ -1010,7 +1019,9 @@
window.location.href = '/rental/agreement/' + orderId + '/' + token + '/thank-you';
}, 1500);
} else {
errDiv.textContent = res.error || 'An error occurred.';
var errMsg = res.error;
if (typeof errMsg === 'object') errMsg = errMsg.message || JSON.stringify(errMsg);
errDiv.textContent = errMsg || 'An error occurred.';
errDiv.classList.remove('d-none');
btn.disabled = false;
btn.textContent = 'Sign Agreement \x26 Authorize Card';

View File

@@ -57,3 +57,10 @@ class ResConfigSettings(models.TransientModel):
"kicks in for short-term rentals. Gives the customer time to "
"return without being charged.",
)
rental_short_term_reminder_hours = fields.Integer(
string="Short-Term Reminder (Hours Before Return)",
config_parameter='fusion_rental.short_term_reminder_hours',
default=2,
help="For short-term rentals, send the renewal reminder this many "
"hours before the scheduled return time. Default is 2 hours.",
)

View File

@@ -2,16 +2,28 @@ import base64
import logging
import uuid
from datetime import timedelta
from zoneinfo import ZoneInfo
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)
# -----------------------------------------------------------------
@@ -233,6 +245,43 @@ class SaleOrder(models.Model):
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:
@@ -350,13 +399,37 @@ class SaleOrder(models.Model):
# 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()
@@ -404,16 +477,39 @@ class SaleOrder(models.Model):
return (start + timedelta(days=offset_days)).date()
def _get_reminder_target_date(self):
"""When to send the renewal reminder (percentage of period before renewal)."""
"""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)
if not self.rental_next_renewal_date:
return False
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()
@@ -422,14 +518,26 @@ class SaleOrder(models.Model):
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."""
"""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)
return fields.Datetime.now() >= grace_deadline
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.
@@ -711,7 +819,7 @@ class SaleOrder(models.Model):
"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 '',
date=self._format_local_dt(self.rental_return_date, '%m/%d/%Y'),
url=cancel_url,
phone=company_phone,
)
@@ -724,7 +832,7 @@ class SaleOrder(models.Model):
@api.model
def _cron_rental_renewal_reminders(self):
"""Cron: send renewal reminders based on percentage of rental period."""
today = fields.Date.today()
today = self._local_date_today()
orders = self.search([
('is_rental_order', '=', True),
('rental_auto_renew', '=', True),
@@ -740,6 +848,9 @@ class SaleOrder(models.Model):
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)
@@ -760,7 +871,7 @@ class SaleOrder(models.Model):
@api.model
def _cron_rental_auto_renewals(self):
"""Cron: auto-renew rentals that have reached their return date."""
today = fields.Date.today()
today = self._local_date_today()
staleness_cutoff = today - timedelta(days=self._RENEWAL_STALENESS_DAYS)
orders = self.search([
@@ -1100,8 +1211,20 @@ class SaleOrder(models.Model):
)
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."""
"""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
@@ -1111,6 +1234,18 @@ class SaleOrder(models.Model):
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',
@@ -1125,8 +1260,24 @@ class SaleOrder(models.Model):
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."""
"""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
@@ -1513,14 +1664,36 @@ class SaleOrder(models.Model):
@api.model
def _cron_rental_deposit_refunds(self):
"""Cron: process deposit refunds after the 3-day hold period."""
today = fields.Date.today()
"""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)
@@ -1571,7 +1744,7 @@ class SaleOrder(models.Model):
@api.model
def _cron_rental_marketing_emails(self):
"""Cron: send purchase marketing email based on percentage of rental period."""
today = fields.Date.today()
today = self._local_date_today()
orders = self.search([
('is_rental_order', '=', True),
('state', '=', 'sale'),

View File

@@ -1,5 +1,9 @@
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
@@ -16,11 +20,10 @@ class SaleOrderLine(models.Model):
help="The rental product line this deposit is associated with.",
)
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
def _ensure_deposit_lines(self):
"""Check rental lines and create missing security deposit lines."""
deposit_vals = []
for line in lines:
for line in self:
if not line.is_rental or line.is_security_deposit:
continue
if not line.order_id.is_rental_order:
@@ -29,9 +32,9 @@ class SaleOrderLine(models.Model):
if deposit_amount <= 0:
continue
existing = line.order_id.order_line.filtered(
lambda l: (
lambda l, src=line: (
l.is_security_deposit
and l.rental_deposit_source_line_id == line
and l.rental_deposit_source_line_id == src
)
)
if existing:
@@ -41,16 +44,36 @@ class SaleOrderLine(models.Model):
'order_id': line.order_id.id,
'product_id': deposit_product.id,
'product_uom_id': deposit_product.uom_id.id,
'name': f"SECURITY DEPOSIT - REFUNDABLE - {line.product_id.display_name}",
'name': (
"SECURITY DEPOSIT - REFUNDABLE UPON RETURN IN "
f"GOOD & CLEAN CONDITION - {line.product_id.display_name}"
),
'product_uom_qty': 1,
'price_unit': deposit_amount,
'is_security_deposit': True,
'rental_deposit_source_line_id': line.id,
})
if deposit_vals:
super().create(deposit_vals)
self.env['sale.order.line'].with_context(
skip_deposit_check=True,
).create(deposit_vals)
return bool(deposit_vals)
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
if not self.env.context.get('skip_deposit_check'):
lines._ensure_deposit_lines()
return lines
def write(self, vals):
res = super().write(vals)
if self.env.context.get('skip_deposit_check'):
return res
if 'is_rental' in vals or 'product_id' in vals:
self._ensure_deposit_lines()
return res
def unlink(self):
deposit_lines = self.env['sale.order.line']
for line in self:

View File

@@ -192,7 +192,7 @@
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Phone</td>
<td><t t-esc="doc.partner_id.phone or doc.partner_id.mobile or ''"/></td>
<td><t t-esc="doc.partner_id.phone or ''"/></td>
<td style="font-weight: bold; background-color: #f5f5f5;">Start Date</td>
<td>
<t t-if="doc.rental_start_date"><span t-field="doc.rental_start_date" t-options="{'widget': 'date'}"/></t>
@@ -373,7 +373,7 @@
<td style="width: 33.34%; padding: 10px 14px; border: none; height: 80px; vertical-align: top;">
<div class="signature-label">DATE</div>
<t t-if="doc.rental_agreement_signed_date">
<div style="min-height: 50px; font-size: 14px; padding-top: 8px;" t-out="doc.rental_agreement_signed_date.strftime('%m/%d/%Y')">Date</div>
<div style="min-height: 50px; font-size: 14px; padding-top: 8px;" t-out="format_datetime(doc.rental_agreement_signed_date, tz=doc.company_id.partner_id.tz or 'America/Toronto', dt_format='MM/dd/yyyy')">Date</div>
</t>
<t t-else=""><div style="border-bottom: 1px solid #000; min-height: 50px; margin-top: 20px;"></div></t>
</td>

View File

@@ -87,6 +87,16 @@
</div>
</div>
</setting>
<setting string="Short-Term Renewal Reminder"
help="How many hours before the return time to send the renewal reminder for short-term rentals.">
<div class="content-group">
<div class="row mt-2">
<label class="col-lg-4 o_light_label" for="rental_short_term_reminder_hours"/>
<field name="rental_short_term_reminder_hours" class="col-lg-2"/>
<span class="col-lg-5 text-muted">hours before return time (default 2 hours)</span>
</div>
</div>
</setting>
</block>
</xpath>
</field>

View File

@@ -76,6 +76,12 @@ class RentalReturnWizard(models.TransientModel):
'qty_to_return': line.qty_delivered - line.qty_returned,
}))
if not lines_vals:
raise UserError(_(
"No items are available for return. Either nothing has been "
"delivered or all items have already been returned."
))
res['line_ids'] = lines_vals
return res
@@ -84,6 +90,12 @@ class RentalReturnWizard(models.TransientModel):
self.ensure_one()
order = self.order_id
if not order._has_completed_delivery():
raise UserError(_(
"Cannot process return: no delivery has been completed "
"for this order."
))
if not self.inspection_photo_ids:
raise UserError(_(
"Inspection photos are required. Please attach at least one "