This commit is contained in:
gsinghpal
2026-02-25 09:40:41 -05:00
parent 0e1aebe60b
commit e71bc503f9
69 changed files with 7537 additions and 82 deletions

View File

@@ -0,0 +1,3 @@
from . import controllers
from . import models
from . import wizard

View File

@@ -0,0 +1,43 @@
{
'name': 'Fusion Rental Enhancement',
'version': '19.0.2.0.0',
'category': 'Sales/Rental',
'sequence': 200,
'summary': "Rental lifecycle: agreements, deposits, auto-renewal, marketing, inspections.",
'description': " ",
'depends': [
'sale_renting',
'sale_loyalty',
'stock',
'fusion_poynt',
'fusion_ringcentral',
'fusion_claims',
'mail',
],
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/product_data.xml',
'data/loyalty_program_data.xml',
'data/mail_template_data.xml',
'data/ir_cron_data.xml',
'wizard/manual_renewal_wizard_views.xml',
'wizard/deposit_deduction_wizard_views.xml',
'report/report_rental_agreement.xml',
'views/product_template_views.xml',
'views/sale_order_views.xml',
'views/renewal_log_views.xml',
'views/cancellation_request_views.xml',
'views/res_config_settings_views.xml',
'views/portal_rental_inspection.xml',
'views/menus.xml',
],
'author': 'Fusion Apps',
'license': 'OPL-1',
'application': False,
'installable': True,
}

View File

@@ -0,0 +1 @@
from . import main

View File

@@ -0,0 +1,305 @@
import logging
from odoo import _, fields, http
from odoo.http import request
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionRentalController(http.Controller):
# =================================================================
# Cancellation (from renewal reminder email)
# =================================================================
@http.route(
'/rental/cancel/<string:token>',
type='http',
auth='public',
website=True,
methods=['GET', 'POST'],
)
def rental_cancel(self, token, **kwargs):
"""Handle rental cancellation requests from email links."""
cancel_request = request.env['rental.cancellation.request'].sudo().search([
('token', '=', token),
('state', '=', 'new'),
], limit=1)
if not cancel_request:
return request.render(
'fusion_rental.cancellation_invalid_page',
{'error': _("This cancellation link is invalid or has already been used.")},
)
if request.httprequest.method == 'POST':
reason = kwargs.get('reason', '')
cancel_request.write({'reason': reason})
cancel_request.action_confirm()
return request.render(
'fusion_rental.cancellation_success_page',
{
'order': cancel_request.order_id,
'partner': cancel_request.partner_id,
},
)
return request.render(
'fusion_rental.cancellation_form_page',
{
'order': cancel_request.order_id,
'partner': cancel_request.partner_id,
'token': token,
},
)
# =================================================================
# Rental Agreement Signing
# =================================================================
@http.route(
'/rental/agreement/<int:order_id>/<string:token>',
type='http',
auth='public',
website=True,
methods=['GET'],
)
def rental_agreement_page(self, order_id, token, **kwargs):
"""Render the rental agreement signing page."""
order = request.env['sale.order'].sudo().browse(order_id)
if (
not order.exists()
or order.rental_agreement_token != token
or order.rental_agreement_signed
):
return request.render(
'fusion_rental.cancellation_invalid_page',
{'error': _("This agreement link is invalid or has already been signed.")},
)
return request.render(
'fusion_rental.agreement_signing_page',
{
'order': order,
'partner': order.partner_id,
'token': token,
'pdf_preview_url': f'/rental/agreement/{order_id}/{token}/pdf',
},
)
@http.route(
'/rental/agreement/<int:order_id>/<string:token>/pdf',
type='http',
auth='public',
methods=['GET'],
)
def rental_agreement_pdf(self, order_id, token, **kwargs):
"""Serve the rental agreement PDF for preview (token-protected)."""
order = request.env['sale.order'].sudo().browse(order_id)
if not order.exists() or order.rental_agreement_token != token:
return request.not_found()
report_name = 'fusion_rental.report_rental_agreement'
report = request.env['ir.actions.report'].sudo()._get_report_from_name(report_name)
if not report:
report = request.env['ir.actions.report'].sudo()._get_report_from_name(
'fusion_claims.report_rental_agreement'
)
report_name = 'fusion_claims.report_rental_agreement'
if not report:
return request.not_found()
pdf_content, _report_type = report.sudo()._render_qweb_pdf(
report_name, [order.id]
)
return request.make_response(
pdf_content,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'inline; filename="Rental Agreement - {order.name}.pdf"'),
],
)
@http.route(
'/rental/agreement/<int:order_id>/<string:token>/sign',
type='json',
auth='public',
methods=['POST'],
)
def rental_agreement_sign(self, order_id, token, **kwargs):
"""Process the agreement signing: save signature and tokenize card."""
order = request.env['sale.order'].sudo().browse(order_id)
if (
not order.exists()
or order.rental_agreement_token != token
or order.rental_agreement_signed
):
return {'success': False, 'error': 'Invalid or expired agreement link.'}
signer_name = kwargs.get('signer_name', '').strip()
signature_data = kwargs.get('signature_data', '')
card_number = kwargs.get('card_number', '').replace(' ', '')
exp_month = kwargs.get('exp_month', '')
exp_year = kwargs.get('exp_year', '')
cvv = kwargs.get('cvv', '')
cardholder_name = kwargs.get('cardholder_name', '').strip()
if not signer_name:
return {'success': False, 'error': 'Full name is required.'}
if not signature_data:
return {'success': False, 'error': 'Signature is required.'}
if not card_number or len(card_number) < 13:
return {'success': False, 'error': 'Valid card number is required.'}
if not exp_month or not exp_year:
return {'success': False, 'error': 'Card expiry is required.'}
if not cvv:
return {'success': False, 'error': 'CVV is required.'}
sig_binary = signature_data
if ',' in sig_binary:
sig_binary = sig_binary.split(',')[1]
try:
payment_token = self._tokenize_card_via_poynt(
order, card_number, exp_month, exp_year, cvv, cardholder_name,
)
except (UserError, Exception) as e:
_logger.error("Card tokenization failed for %s: %s", order.name, e)
return {'success': False, 'error': str(e)}
order.write({
'rental_agreement_signed': True,
'rental_agreement_signature': sig_binary,
'rental_agreement_signer_name': signer_name,
'rental_agreement_signed_date': fields.Datetime.now(),
'rental_payment_token_id': payment_token.id if payment_token else False,
})
order.message_post(
body=_("Rental agreement signed by %s.", signer_name),
)
return {
'success': True,
'message': 'Agreement signed successfully. Thank you!',
}
def _tokenize_card_via_poynt(
self, order, card_number, exp_month, exp_year, cvv, cardholder_name,
):
"""Tokenize a card through the Poynt API and create a payment.token."""
provider = request.env['payment.provider'].sudo().search([
('code', '=', 'poynt'),
('state', '!=', 'disabled'),
], limit=1)
if not provider:
raise UserError(_("Poynt payment provider is not configured."))
from odoo.addons.fusion_poynt import utils as poynt_utils
funding_source = {
'type': 'CREDIT_DEBIT',
'card': {
'number': card_number,
'expirationMonth': int(exp_month),
'expirationYear': int(exp_year),
'cardHolderFullName': cardholder_name,
},
'verificationData': {
'cvData': cvv,
},
'entryDetails': {
'customerPresenceStatus': 'MOTO',
'entryMode': 'KEYED',
},
}
minor_amount = poynt_utils.format_poynt_amount(0.00, order.currency_id)
txn_payload = {
'action': 'VERIFY',
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
'currency': order.currency_id.name,
},
'fundingSource': funding_source,
'context': {
'source': 'WEB',
'sourceApp': 'odoo.fusion_rental',
'businessId': provider.poynt_business_id,
},
'notes': f"Card tokenization for {order.name}",
}
result = provider._poynt_make_request('POST', 'transactions', payload=txn_payload)
card_data = result.get('fundingSource', {}).get('card', {})
card_id = card_data.get('cardId', '')
last_four = card_data.get('numberLast4', card_number[-4:])
card_type = card_data.get('type', 'UNKNOWN')
payment_method = request.env['payment.method'].sudo().search(
[('code', '=', 'card')], limit=1,
)
if not payment_method:
payment_method = request.env['payment.method'].sudo().search(
[('code', 'in', ('visa', 'mastercard'))], limit=1,
)
token = request.env['payment.token'].sudo().create({
'provider_id': provider.id,
'payment_method_id': payment_method.id if payment_method else False,
'partner_id': order.partner_id.id,
'poynt_card_id': card_id,
'payment_details': f"{card_type} ending in {last_four}",
})
return token
# =================================================================
# Purchase Interest (from marketing email)
# =================================================================
@http.route(
'/rental/purchase-interest/<int:order_id>/<string:token>',
type='http',
auth='public',
website=True,
methods=['GET'],
)
def rental_purchase_interest(self, order_id, token, **kwargs):
"""Handle customer expressing purchase interest from marketing email."""
order = request.env['sale.order'].sudo().browse(order_id)
if (
not order.exists()
or order.rental_agreement_token != token
):
return request.render(
'fusion_rental.cancellation_invalid_page',
{'error': _("This link is invalid.")},
)
if not order.rental_purchase_interest:
order.rental_purchase_interest = True
order.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=fields.Date.today(),
summary=_("Customer interested in purchasing rental product"),
note=_(
"Customer %s expressed interest in purchasing the rental "
"product from order %s. Please follow up.",
order.partner_id.name, order.name,
),
user_id=order.user_id.id or request.env.uid,
)
order.message_post(
body=_("Customer expressed purchase interest via marketing email."),
)
return request.render(
'fusion_rental.purchase_interest_success_page',
{'order': order, 'partner': order.partner_id},
)

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
All crons ship INACTIVE to avoid processing existing data on install.
Activate from Settings > Technical > Scheduled Actions when ready.
-->
<record id="ir_cron_rental_auto_renewal" model="ir.cron">
<field name="name">Rental: Auto-Renewal</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_rental_auto_renewals()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">False</field>
<field name="priority">5</field>
</record>
<record id="ir_cron_rental_renewal_reminder" model="ir.cron">
<field name="name">Rental: Renewal Reminders (3 Days Before)</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="active">False</field>
<field name="priority">10</field>
</record>
<record id="ir_cron_rental_marketing_email" model="ir.cron">
<field name="name">Rental: Day-7 Purchase Marketing Email</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_rental_marketing_emails()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">False</field>
<field name="priority">15</field>
</record>
<record id="ir_cron_rental_deposit_refund" model="ir.cron">
<field name="name">Rental: Security Deposit Refund (3-Day Hold)</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_rental_deposit_refunds()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">False</field>
<field name="priority">5</field>
</record>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="rental_purchase_loyalty_program" model="loyalty.program">
<field name="name">Rental to Purchase - First Month Credit</field>
<field name="program_type">coupons</field>
<field name="applies_on">current</field>
<field name="trigger">with_code</field>
<field name="portal_visible">True</field>
<field name="portal_point_name">Rental Credit ($)</field>
</record>
<record id="rental_purchase_loyalty_reward" model="loyalty.reward">
<field name="program_id" ref="rental_purchase_loyalty_program"/>
<field name="reward_type">discount</field>
<field name="discount">1</field>
<field name="discount_mode">per_point</field>
<field name="discount_applicability">order</field>
<field name="required_points">1</field>
<field name="description">First month rental credit</field>
</record>
</odoo>

View File

@@ -0,0 +1,616 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- 1. Renewal Reminder - 3 days before expiry -->
<!-- ============================================================ -->
<record id="mail_template_rental_reminder" model="mail.template">
<field name="name">Rental: Renewal Reminder</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Rental {{ object.name }} Renews Soon</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#D69E2E;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#D69E2E;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</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>.
</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;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;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">If you would like to continue your rental, no action is needed. If you wish to cancel and schedule a pickup, click the button below.</p>
</div>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-attf-href="{{ (object.get_base_url() or '') }}/rental/cancel/{{ ctx.get('cancel_token', '') }}" style="background-color:#E53E3E;color:#fff;padding:12px 30px;text-decoration:none;border-radius:4px;font-size:14px;font-weight:600;display:inline-block;">Request Cancellation &amp; Pickup</a>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- 2. Renewal Confirmation -->
<!-- ============================================================ -->
<record id="mail_template_rental_renewed" model="mail.template">
<field name="name">Rental: Renewal Confirmation</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Rental {{ object.name }} Renewed</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#38a169;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Rental Renewed</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your rental <strong style="color:#2d3748;"><t t-out="object.name"/></strong> has been successfully renewed.
</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;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;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;" t-if="ctx.get('payment_ok')">Payment has been collected from your card on file. No further action is required.</p>
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;" t-if="not ctx.get('payment_ok')">Our team will be in touch regarding payment for this renewal period.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- 3. Payment Receipt -->
<!-- ============================================================ -->
<record id="mail_template_rental_payment_receipt" model="mail.template">
<field name="name">Rental: Payment Receipt</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Payment Receipt {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Payment Receipt</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Payment has been collected for rental <strong style="color:#2d3748;"><t t-out="object.name"/></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;">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;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;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Thank you for your continued business. If you have any questions about this payment, please do not hesitate to contact us.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- 4. Cancellation Confirmation -->
<!-- ============================================================ -->
<record id="mail_template_rental_cancellation_confirmed" model="mail.template">
<field name="name">Rental: Cancellation Confirmed</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Cancellation Received {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#D69E2E;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#D69E2E;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Cancellation Request Received</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your cancellation request for rental <strong style="color:#2d3748;"><t t-out="object.name"/></strong> has been received. Your rental will <strong style="color:#2d3748;">no longer auto-renew</strong>.
</p>
<div style="border-left:3px solid #D69E2E;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Our team will contact you shortly to schedule a pickup. If you have any questions in the meantime, please do not hesitate to reach out.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- 5. Send Agreement for Signing -->
<!-- ============================================================ -->
<record id="mail_template_rental_agreement" model="mail.template">
<field name="name">Rental: Agreement for Signing</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Rental Agreement {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Rental Agreement</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 ready. Please review and sign the rental agreement and provide your payment details.
</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;">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;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;">
<a t-attf-href="{{ (object.get_base_url() or '') }}/rental/agreement/{{ object.id }}/{{ object.rental_agreement_token or '' }}" style="background-color:#2B6CB0;color:#fff;padding:12px 30px;text-decoration:none;border-radius:4px;font-size:14px;font-weight:600;display:inline-block;">Review &amp; Sign Agreement</a>
</div>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Click the button above to review your rental agreement, provide your payment card details, and sign digitally. If you have any questions, please contact us.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- 6. Day-7 Marketing Email - Purchase Conversion -->
<!-- ============================================================ -->
<record id="mail_template_rental_marketing" model="mail.template">
<field name="name">Rental: Purchase Conversion Offer</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Make Your Rental Yours {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#319795;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#319795;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Love Your Rental? Make It Yours!</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
We hope you are enjoying your rental! Did you know you can purchase the product and apply your <strong style="color:#2d3748;">first month's rental as a discount</strong>?
</p>
<t t-if="object.rental_purchase_coupon_id">
<div style="padding:16px 20px;border:2px dashed #319795;border-radius:6px;text-align:center;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;">Your Exclusive Coupon</p>
<p style="margin:0;font-size:24px;font-weight:700;color:#319795;letter-spacing:2px;" t-out="object.rental_purchase_coupon_id.code or ''">CODE</p>
</div>
</t>
<p style="color:#718096;font-size:13px;line-height:1.5;margin:0 0 24px 0;">Delivery fee is not included in this offer.</p>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-attf-href="{{ (object.get_base_url() or '') }}/rental/purchase-interest/{{ object.id }}/{{ object.rental_agreement_token or '' }}" style="background-color:#38a169;color:#fff;padding:12px 30px;text-decoration:none;border-radius:4px;font-size:14px;font-weight:600;display:inline-block;">I'm Interested!</a>
</div>
<div style="border-left:3px solid #319795;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Click the button above to let us know, and a member of our team will follow up to discuss the details.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- 7. Security Deposit Refund Confirmation -->
<!-- ============================================================ -->
<record id="mail_template_rental_deposit_refund" model="mail.template">
<field name="name">Rental: Security Deposit Refunded</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Security Deposit Refunded {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#38a169;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Security Deposit Refunded</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your security deposit for rental order <strong style="color:#2d3748;"><t t-out="object.name"/></strong> has been refunded.
</p>
<t t-if="object.rental_deposit_invoice_id">
<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;">Refund Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Refund Amount</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_deposit_invoice_id.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Card</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;">**** **** **** <t t-out="object._get_card_last_four() or '****'"/></td></tr>
</table>
</t>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Please allow <strong>7 to 10 business days</strong> for the refund to appear on your statement, depending on your bank.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- 8. Thank You + Google Review -->
<!-- ============================================================ -->
<record id="mail_template_rental_thank_you" model="mail.template">
<field name="name">Rental: Thank You + Review</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Thank You {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Thank You!</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Thank you for choosing <strong style="color:#2d3748;"><t t-out="object.company_id.name or ''"/></strong> for your rental needs. Your rental order <strong style="color:#2d3748;"><t t-out="object.name"/></strong> has been completed and closed.
</p>
<t t-if="ctx.get('google_review_url')">
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">We would greatly appreciate your feedback. A review helps us serve you and others better!</p>
</div>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-att-href="ctx.get('google_review_url', '#')" style="background-color:#2B6CB0;color:#fff;padding:12px 30px;text-decoration:none;border-radius:4px;font-size:14px;font-weight:600;display:inline-block;">Leave a Google Review</a>
</div>
</t>
<t t-else="">
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">We hope you had a great experience. If you need anything in the future, do not hesitate to reach out.</p>
</div>
</t>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- QWeb Pages -->
<!-- ============================================================ -->
<!-- Cancellation Form -->
<record id="cancellation_form_page" model="ir.ui.view">
<field name="name">Rental Cancellation Form</field>
<field name="type">qweb</field>
<field name="key">fusion_rental.cancellation_form_page</field>
<field name="arch" type="xml">
<t t-call="web.frontend_layout">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<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>
<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>
<button type="submit" class="btn btn-danger w-100">Confirm Cancellation &amp; Request Pickup</button>
</form>
</div>
</div>
</div>
</div>
</div>
</t>
</field>
</record>
<!-- Cancellation Success -->
<record id="cancellation_success_page" model="ir.ui.view">
<field name="name">Rental Cancellation Success</field>
<field name="type">qweb</field>
<field name="key">fusion_rental.cancellation_success_page</field>
<field name="arch" type="xml">
<t t-call="web.frontend_layout">
<div class="container py-5"><div class="row justify-content-center"><div class="col-md-8 col-lg-6"><div class="card shadow-sm">
<div class="card-header bg-success text-white"><h3 class="mb-0">Cancellation Request Received</h3></div>
<div class="card-body text-center">
<i class="fa fa-check-circle text-success" style="font-size:48px;"/>
<p class="lead mt-3">Your cancellation for <strong t-out="order.name">SO001</strong> has been received.</p>
<p>Our team will contact you to schedule a pickup.</p>
</div>
</div></div></div></div>
</t>
</field>
</record>
<!-- Invalid Link -->
<record id="cancellation_invalid_page" model="ir.ui.view">
<field name="name">Rental Invalid Link</field>
<field name="type">qweb</field>
<field name="key">fusion_rental.cancellation_invalid_page</field>
<field name="arch" type="xml">
<t t-call="web.frontend_layout">
<div class="container py-5"><div class="row justify-content-center"><div class="col-md-8 col-lg-6"><div class="card shadow-sm">
<div class="card-header bg-danger text-white"><h3 class="mb-0">Invalid Request</h3></div>
<div class="card-body text-center">
<i class="fa fa-times-circle text-danger" style="font-size:48px;"/>
<p class="lead mt-3" t-out="error">This link is invalid or has already been used.</p>
<p>Please contact our office for assistance.</p>
</div>
</div></div></div></div>
</t>
</field>
</record>
<!-- Agreement Signing Page -->
<record id="agreement_signing_page" model="ir.ui.view">
<field name="name">Rental Agreement Signing</field>
<field name="type">qweb</field>
<field name="key">fusion_rental.agreement_signing_page</field>
<field name="arch" type="xml">
<t t-call="web.frontend_layout">
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white"><h3 class="mb-0">Rental Agreement</h3></div>
<div class="card-body">
<h5>Order: <t t-out="order.name">SO001</t></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>
</p>
<h5 class="mt-4">Order Summary</h5>
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr><th>Description</th><th class="text-center" style="width:60px;">Qty</th><th class="text-end" style="width:120px;">Price</th></tr>
</thead>
<tbody>
<!-- Rental items -->
<t t-foreach="order.order_line.filtered(lambda l: l.is_rental and not l.is_security_deposit)" t-as="line">
<tr>
<td t-out="line.product_id.name">Product</td>
<td class="text-center" t-out="int(line.product_uom_qty)">1</td>
<td class="text-end"><t t-out="order.currency_id.symbol or '$'"/><t t-out="'%.2f' % line.price_subtotal"/></td>
</tr>
</t>
<!-- Non-rental, non-deposit items (delivery, installation, etc.) -->
<t t-foreach="order.order_line.filtered(lambda l: not l.is_rental and not l.is_security_deposit and not l.display_type)" t-as="line">
<tr>
<td t-out="line.product_id.name">Service</td>
<td class="text-center" t-out="int(line.product_uom_qty)">1</td>
<td class="text-end"><t t-out="order.currency_id.symbol or '$'"/><t t-out="'%.2f' % line.price_subtotal"/></td>
</tr>
</t>
<!-- Security deposit lines -->
<t t-foreach="order.order_line.filtered(lambda l: l.is_security_deposit)" t-as="line">
<tr class="table-warning">
<td><strong>SECURITY DEPOSIT - REFUNDABLE</strong>
<br/><small class="text-muted">REFUNDABLE UPON RETURN IN GOOD &amp; CLEAN CONDITION</small></td>
<td class="text-center" t-out="int(line.product_uom_qty)">1</td>
<td class="text-end"><t t-out="order.currency_id.symbol or '$'"/><t t-out="'%.2f' % line.price_subtotal"/></td>
</tr>
</t>
</tbody>
<tfoot>
<tr class="table-light">
<td colspan="2" class="text-end"><strong>Total</strong></td>
<td class="text-end"><strong><t t-out="order.currency_id.symbol or '$'"/><t t-out="'%.2f' % order.amount_total"/></strong></td>
</tr>
</tfoot>
</table>
<div class="alert alert-info mt-2 small mb-4">
<strong>Payment will be processed in two portions:</strong>
<ol class="mb-0 mt-1">
<li>Rental charges, delivery and services</li>
<li>Refundable security deposit (separate invoice)</li>
</ol>
</div>
<h5 class="mt-4">Rental Agreement</h5>
<p class="text-muted small">Please review the full rental agreement below before signing.</p>
<div style="border:1px solid #dee2e6; border-radius:4px; overflow:hidden; margin-bottom:20px; background:#f8f9fa;">
<iframe t-att-src="pdf_preview_url"
style="width:100%; height:600px; border:none;"
title="Rental Agreement Preview"/>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="agree_terms" required="1"/>
<label class="form-check-label" for="agree_terms">
I have read and agree to the terms and conditions of the Rental Agreement.
</label>
</div>
<hr/>
<h5>Credit Card Authorization</h5>
<p class="text-muted small">Your card will be securely tokenized. We do not store your full card number.</p>
<div class="row g-3">
<div class="col-12"><label class="form-label">Cardholder Name</label><input type="text" id="cardholder_name" class="form-control" placeholder="Name on card" required="1"/></div>
<div class="col-12"><label class="form-label">Card Number</label><input type="text" id="card_number" class="form-control" placeholder="1234 5678 9012 3456" maxlength="19" required="1"/></div>
<div class="col-4"><label class="form-label">Month</label><input type="text" id="exp_month" class="form-control" placeholder="MM" maxlength="2" required="1"/></div>
<div class="col-4"><label class="form-label">Year</label><input type="text" id="exp_year" class="form-control" placeholder="YYYY" maxlength="4" required="1"/></div>
<div class="col-4"><label class="form-label">CVV</label><input type="password" id="cvv" class="form-control" placeholder="***" maxlength="4" required="1"/></div>
</div>
<hr/>
<h5>Signature</h5>
<div class="mb-3"><label class="form-label">Full Name (Print)</label><input type="text" id="signer_name" class="form-control" t-att-value="partner.name" required="1"/></div>
<div class="mb-3">
<label class="form-label">Signature</label>
<div style="border:1px solid #ccc; border-radius:4px; background:#fff;">
<canvas id="signature_pad" width="600" height="200" style="width:100%; height:200px; cursor:crosshair;"/>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" id="clear_signature">Clear</button>
</div>
<div id="agreement_error" class="alert alert-danger d-none"></div>
<div id="agreement_success" class="alert alert-success d-none"></div>
<button type="button" id="btn_sign_agreement" class="btn btn-primary btn-lg w-100 mt-3"
t-att-data-order-id="order.id" t-att-data-token="token">
Sign Agreement &amp; Authorize Card
</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var canvas = document.getElementById('signature_pad');
var ctx = canvas.getContext('2d');
var drawing = false;
function getPos(e) {
var rect = canvas.getBoundingClientRect();
var x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
var y = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top;
return {x: x * (canvas.width / rect.width), y: y * (canvas.height / rect.height)};
}
canvas.addEventListener('mousedown', function(e) { drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); });
canvas.addEventListener('mousemove', function(e) { if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); });
canvas.addEventListener('mouseup', function() { drawing = false; });
canvas.addEventListener('touchstart', function(e) { e.preventDefault(); drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); });
canvas.addEventListener('touchmove', function(e) { e.preventDefault(); if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); });
canvas.addEventListener('touchend', function() { drawing = false; });
ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.lineCap = 'round';
document.getElementById('clear_signature').addEventListener('click', function() { ctx.clearRect(0, 0, canvas.width, canvas.height); });
document.getElementById('btn_sign_agreement').addEventListener('click', function() {
var btn = this;
var errDiv = document.getElementById('agreement_error');
var successDiv = document.getElementById('agreement_success');
errDiv.classList.add('d-none');
successDiv.classList.add('d-none');
var agreeCheck = document.getElementById('agree_terms');
if (agreeCheck &amp;&amp; !agreeCheck.checked) {
errDiv.textContent = 'You must agree to the rental agreement terms before signing.';
errDiv.classList.remove('d-none');
return;
}
btn.disabled = true;
btn.textContent = 'Processing...';
var orderId = btn.dataset.orderId;
var token = btn.dataset.token;
var sigData = canvas.toDataURL('image/png');
fetch('/rental/agreement/' + orderId + '/' + token + '/sign', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0', method: 'call', id: 1,
params: {
order_id: parseInt(orderId), token: token,
signer_name: document.getElementById('signer_name').value,
signature_data: sigData,
card_number: document.getElementById('card_number').value,
exp_month: document.getElementById('exp_month').value,
exp_year: document.getElementById('exp_year').value,
cvv: document.getElementById('cvv').value,
cardholder_name: document.getElementById('cardholder_name').value,
}
})
}).then(function(r) { return r.json(); }).then(function(data) {
var res = data.result || data;
if (res.success) {
successDiv.textContent = res.message || 'Signed successfully!';
successDiv.classList.remove('d-none');
btn.textContent = 'Signed!';
} else {
errDiv.textContent = res.error || 'An error occurred.';
errDiv.classList.remove('d-none');
btn.disabled = false;
btn.textContent = 'Sign Agreement &amp; Authorize Card';
}
}).catch(function(e) {
errDiv.textContent = 'Network error. Please try again.';
errDiv.classList.remove('d-none');
btn.disabled = false;
btn.textContent = 'Sign Agreement &amp; Authorize Card';
});
});
});
</script>
</t>
</field>
</record>
<!-- Purchase Interest Success -->
<record id="purchase_interest_success_page" model="ir.ui.view">
<field name="name">Rental Purchase Interest</field>
<field name="type">qweb</field>
<field name="key">fusion_rental.purchase_interest_success_page</field>
<field name="arch" type="xml">
<t t-call="web.frontend_layout">
<div class="container py-5"><div class="row justify-content-center"><div class="col-md-8 col-lg-6"><div class="card shadow-sm">
<div class="card-header bg-success text-white"><h3 class="mb-0">Thank You for Your Interest!</h3></div>
<div class="card-body text-center">
<i class="fa fa-shopping-cart text-success" style="font-size:48px;"/>
<p class="lead mt-3">We've received your interest in purchasing your rental product from order <strong t-out="order.name">SO001</strong>.</p>
<p>A member of our team will contact you shortly to discuss the details and schedule delivery.</p>
</div>
</div></div></div></div>
</t>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="product_security_deposit" model="product.product">
<field name="name">SECURITY DEPOSIT - REFUNDABLE</field>
<field name="default_code">SECURITY-DEPOSIT</field>
<field name="type">service</field>
<field name="list_price">0.0</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
<field name="invoice_policy">order</field>
<field name="taxes_id" eval="[(5,)]"/>
<field name="supplier_taxes_id" eval="[(5,)]"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,6 @@
from . import sale_order
from . import sale_order_line
from . import renewal_log
from . import cancellation_request
from . import stock_warehouse
from . import res_config_settings

View File

@@ -0,0 +1,123 @@
import uuid
from odoo import _, api, fields, models
class RentalCancellationRequest(models.Model):
_name = 'rental.cancellation.request'
_description = 'Rental Cancellation Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'request_date desc'
_rec_name = 'display_name'
order_id = fields.Many2one(
'sale.order',
string="Rental Order",
required=True,
ondelete='cascade',
index=True,
)
partner_id = fields.Many2one(
related='order_id.partner_id',
store=True,
string="Customer",
)
request_date = fields.Datetime(
string="Request Date",
default=fields.Datetime.now,
)
requested_pickup_date = fields.Datetime(string="Requested Pickup Date")
reason = fields.Text(string="Reason")
state = fields.Selection(
[
('new', 'New'),
('confirmed', 'Confirmed'),
('pickup_scheduled', 'Pickup Scheduled'),
('completed', 'Completed'),
('rejected', 'Rejected'),
],
string="Status",
default='new',
required=True,
tracking=True,
)
assigned_user_id = fields.Many2one(
'res.users',
string="Assigned To",
tracking=True,
)
pickup_activity_id = fields.Many2one(
'mail.activity',
string="Pickup Activity",
ondelete='set null',
)
token = fields.Char(
string="Security Token",
default=lambda self: uuid.uuid4().hex,
copy=False,
index=True,
)
def _compute_display_name(self):
for rec in self:
rec.display_name = (
f"{rec.order_id.name or 'New'} - {rec.partner_id.name or 'Customer'}"
)
def action_confirm(self):
"""Confirm the cancellation and stop auto-renewal."""
self.ensure_one()
self.order_id.write({'rental_auto_renew': False})
self.write({'state': 'confirmed'})
self._schedule_pickup_activity()
self._send_cancellation_confirmation()
def action_schedule_pickup(self):
"""Mark pickup as scheduled."""
self.ensure_one()
self.write({'state': 'pickup_scheduled'})
def action_complete(self):
"""Mark the cancellation and pickup as completed."""
self.ensure_one()
self.write({'state': 'completed'})
if self.pickup_activity_id and not self.pickup_activity_id.date_done:
self.pickup_activity_id.action_done()
def action_reject(self):
"""Reject the cancellation request."""
self.ensure_one()
self.write({'state': 'rejected'})
def _schedule_pickup_activity(self):
"""Create a to-do activity on the sale order for staff to schedule pickup."""
self.ensure_one()
assigned_user = (
self.assigned_user_id
or self.order_id.user_id
or self.env.user
)
activity = self.order_id.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=fields.Date.today(),
summary=_(
"Schedule rental pickup for %s",
self.partner_id.name or self.order_id.partner_id.name,
),
note=_(
"Customer has requested cancellation and pickup for rental %s. "
"Please contact them to schedule a pickup.",
self.order_id.name,
),
user_id=assigned_user.id,
)
self.pickup_activity_id = activity
def _send_cancellation_confirmation(self):
"""Send confirmation email to the customer."""
template = self.env.ref(
'fusion_rental.mail_template_rental_cancellation_confirmed',
raise_if_not_found=False,
)
if template:
template.send_mail(self.order_id.id, force_send=True)

View File

@@ -0,0 +1,74 @@
from odoo import fields, models
class RentalRenewalLog(models.Model):
_name = 'rental.renewal.log'
_description = 'Rental Renewal Log'
_order = 'create_date desc'
_rec_name = 'display_name'
order_id = fields.Many2one(
'sale.order',
string="Rental Order",
required=True,
ondelete='cascade',
index=True,
)
partner_id = fields.Many2one(
related='order_id.partner_id',
store=True,
string="Customer",
)
renewal_number = fields.Integer(
string="Renewal #",
required=True,
)
previous_start_date = fields.Datetime(string="Previous Start")
previous_return_date = fields.Datetime(string="Previous Return")
new_start_date = fields.Datetime(string="New Start")
new_return_date = fields.Datetime(string="New Return")
invoice_id = fields.Many2one(
'account.move',
string="Invoice",
ondelete='set null',
)
payment_status = fields.Selection(
[
('pending', 'Pending'),
('paid', 'Paid'),
('failed', 'Failed'),
],
string="Payment Status",
default='pending',
)
payment_transaction_id = fields.Many2one(
'payment.transaction',
string="Payment Transaction",
ondelete='set null',
)
renewal_type = fields.Selection(
[
('automatic', 'Automatic'),
('manual', 'Manual'),
],
string="Renewal Type",
required=True,
)
state = fields.Selection(
[
('draft', 'Draft'),
('done', 'Done'),
('failed', 'Failed'),
('cancelled', 'Cancelled'),
],
string="Status",
default='draft',
required=True,
)
notes = fields.Text(string="Notes")
def _compute_display_name(self):
for rec in self:
rec.display_name = (
f"{rec.order_id.name or 'New'} - Renewal #{rec.renewal_number}"
)

View File

@@ -0,0 +1,19 @@
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
rental_google_review_url = fields.Char(
string="Google Review URL",
config_parameter='fusion_rental.google_review_url',
help="Google Review link shown in thank-you emails after rental close. "
"For multi-location, set per warehouse in Inventory > Configuration > Warehouses.",
)
rental_deposit_hold_days = fields.Integer(
string="Deposit Hold Period (Days)",
config_parameter='fusion_rental.deposit_hold_days',
default=3,
help="Number of days to hold the security deposit after pickup before "
"processing the refund. Default is 3 days.",
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
from odoo import api, fields, models
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
is_security_deposit = fields.Boolean(
string="Is Security Deposit",
default=False,
help="Marks this line as a security deposit for a rental product.",
)
rental_deposit_source_line_id = fields.Many2one(
'sale.order.line',
string="Deposit For",
ondelete='cascade',
help="The rental product line this deposit is associated with.",
)
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
deposit_vals = []
for line in lines:
if not line.is_rental or line.is_security_deposit:
continue
if not line.order_id.is_rental_order:
continue
deposit_amount = line.order_id._compute_deposit_amount_for_line(line)
if deposit_amount <= 0:
continue
existing = line.order_id.order_line.filtered(
lambda l: (
l.is_security_deposit
and l.rental_deposit_source_line_id == line
)
)
if existing:
continue
deposit_product = line.order_id._get_deposit_product()
deposit_vals.append({
'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}",
'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)
return lines
def unlink(self):
deposit_lines = self.env['sale.order.line']
for line in self:
if line.is_rental and not line.is_security_deposit:
deposit_lines |= line.order_id.order_line.filtered(
lambda l: l.rental_deposit_source_line_id == line
)
if deposit_lines:
deposit_lines.unlink()
return super().unlink()

View File

@@ -0,0 +1,10 @@
from odoo import fields, models
class StockWarehouse(models.Model):
_inherit = 'stock.warehouse'
google_review_url = fields.Char(
string="Google Review URL",
help="Paste the Google Review link for this location.",
)

View File

@@ -0,0 +1,399 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Fusion Rental Enhancement
License OPL-1 (Odoo Proprietary License v1.0)
Rental Agreement Document - Compact 2-Page Layout
-->
<odoo>
<template id="report_rental_agreement">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id"/>
<style>
.fc-rental { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
.fc-rental h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
.fc-rental h2 { color: #0066a1; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
.fc-rental p { margin: 2px 0; text-align: justify; }
.fc-rental .parties { font-size: 8pt; margin-bottom: 8px; }
.fc-rental .intro { margin-bottom: 8px; font-size: 8pt; }
.fc-rental table { width: 100%; border-collapse: collapse; }
.fc-rental table.bordered, .fc-rental table.bordered th, .fc-rental table.bordered td { border: 1px solid #000; }
.fc-rental th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; font-size: 8pt; }
.fc-rental td { padding: 3px 5px; vertical-align: top; font-size: 8pt; }
.fc-rental .text-center { text-align: center; }
.fc-rental .text-right { text-align: right; }
.fc-rental .info-header { background-color: #f5f5f5; color: #333; font-weight: bold; }
/* Two-column layout for terms */
.fc-rental .terms-container { column-count: 2; column-gap: 20px; margin-top: 10px; }
.fc-rental .term-section { break-inside: avoid; margin-bottom: 8px; }
/* Credit card section - 15% taller */
.fc-rental .cc-section { margin-top: 12px; padding: 12px; border: 2px solid #0066a1; background-color: #f8f9fa; }
.fc-rental .cc-title { font-size: 10pt; font-weight: bold; color: #0066a1; margin-bottom: 10px; text-align: center; }
.fc-rental .cc-box { border: 1px solid #000; display: inline-block; width: 21px; height: 21px; text-align: center; background: white; }
.fc-rental .authorization-text { font-size: 7pt; margin-top: 10px; font-style: italic; }
/* Signature - 40% taller */
.fc-rental .signature-section { margin-top: 15px; }
.fc-rental .signature-box { border: 1px solid #000; padding: 12px; }
.fc-rental .signature-line { border-bottom: 1px solid #000; min-height: 35px; margin-bottom: 5px; }
.fc-rental .signature-label { font-size: 7pt; color: #666; }
.fc-rental .text-end { text-align: right; }
</style>
<div class="fc-rental">
<div class="page">
<!-- ============================================================ -->
<!-- PAGE 1: TERMS AND CONDITIONS -->
<!-- ============================================================ -->
<h1>RENTAL AGREEMENT</h1>
<!-- Parties - Compact -->
<div class="parties">
<strong>BETWEEN:</strong> <t t-esc="company.name"/> ("Company")
<strong style="margin-left: 20px;">AND:</strong> <t t-esc="doc.partner_id.name"/> ("Renter")
</div>
<!-- Introduction -->
<div class="intro">
<p><t t-esc="company.name"/> rents to the Renter medical equipment (hospital beds, patient lifts, trapeze, over-bed tables, mobility scooters, electric wheelchairs, manual wheelchairs, stairlifts, ceiling lifts and lift chairs) subject to the terms and conditions set forth in this Rental Agreement.</p>
</div>
<!-- Terms and Conditions in Two Columns -->
<div class="terms-container">
<div class="term-section">
<h2>1. Ownership and Condition of Equipment</h2>
<p>The medical equipment is the property of <t t-esc="company.name"/> and is provided in good condition. The Renter shall return the equipment in the same condition as when received, subject to normal wear and tear. <t t-esc="company.name"/> reserves the right to inspect the equipment upon its return and may repossess it without prior notice if it is being used in violation of this agreement.</p>
</div>
<div class="term-section">
<h2>2. Cancellation Policy</h2>
<p>The Renter may cancel the order before delivery and will be charged twenty-five percent (25%) of the total rental cost. If the order is canceled during the rental period after delivery, no refund will be provided.</p>
</div>
<div class="term-section">
<h2>3. Security Deposit</h2>
<p>The security deposit will be returned after an inspection of the equipment. If the equipment has any damage, the cost of repairs will be deducted from the security deposit. If the security deposit is insufficient to cover the damages, the credit card on file will be charged for the remaining amount. Security deposit refunds may take 4 to 15 business days to process. <t t-esc="company.name"/> is not responsible for delays caused by the Renter's financial institution.</p>
</div>
<div class="term-section">
<h2>4. Liability for Loss or Damage</h2>
<p><t t-esc="company.name"/> shall not be liable for any loss of or damage to property left, lost, damaged, stolen, stored, or transported by the Renter or any other person using the medical equipment. The Renter assumes all risks associated with such loss or damage and waives any claims against <t t-esc="company.name"/>. The Renter agrees to defend, indemnify, and hold <t t-esc="company.name"/> harmless against all claims arising from such loss or damage.</p>
</div>
<div class="term-section">
<h2>5. Risk and Liability</h2>
<p>The Renter assumes all risk and liability for any loss, damage, injury, or death resulting from the use or operation of the medical equipment. <t t-esc="company.name"/> is not responsible for any acts or omissions of the Renter or the Renter's agents, servants, or employees.</p>
</div>
<div class="term-section">
<h2>6. Renter Responsibilities</h2>
<p>The Renter is responsible for the full cost of replacement for any damage, loss, theft, or destruction of the medical equipment. <t t-esc="company.name"/> may charge the Renter's credit card for repair or replacement costs as deemed necessary. The equipment must not be used by individuals under the age of 18, under the influence of intoxicants or narcotics, or in an unsafe manner.</p>
</div>
<div class="term-section">
<h2>7. Indemnification</h2>
<p>The Renter shall indemnify, defend, and hold harmless <t t-esc="company.name"/>, its agents, officers, and employees, from any claims, demands, actions, or causes of action arising from the use or operation of the medical equipment, except where caused by <t t-esc="company.name"/>'s gross negligence or willful misconduct.</p>
</div>
<div class="term-section">
<h2>8. Accident Notification</h2>
<p>The Renter must immediately notify <t t-esc="company.name"/> of any accidents, damages, or incidents involving the medical equipment.</p>
</div>
<div class="term-section">
<h2>9. Costs and Expenses</h2>
<p>The Renter agrees to cover all costs, expenses, and attorney's fees incurred by <t t-esc="company.name"/> in collecting overdue payments, recovering possession of the equipment, or enforcing claims for damage or loss.</p>
</div>
<div class="term-section">
<h2>10. Independent Status</h2>
<p>The Renter or any driver of the equipment shall not be considered an agent or employee of <t t-esc="company.name"/>.</p>
</div>
<div class="term-section">
<h2>11. Binding Obligations</h2>
<p>Any individual signing this agreement on behalf of a corporation or other entity shall be personally liable for all obligations under this agreement. This agreement is binding upon the heirs, executors, administrators, and assigns of the Renter.</p>
</div>
<div class="term-section">
<h2>12. Refusal of Service</h2>
<p><t t-esc="company.name"/> reserves the right to refuse rental to any individual or entity at its sole discretion.</p>
</div>
<div class="term-section">
<h2>13. Governing Law</h2>
<p>This Agreement shall be governed by and construed in accordance with the laws of the jurisdiction in which <t t-esc="company.name"/> operates.</p>
</div>
<div class="term-section">
<h2>14. Entire Agreement</h2>
<p>This Agreement constitutes the entire understanding between the parties concerning the rental of medical equipment and supersedes all prior agreements, representations, or understandings, whether written or oral.</p>
</div>
</div>
<!-- ============================================================ -->
<!-- PAGE 2: RENTAL DETAILS, PAYMENT, AND SIGNATURE -->
<!-- ============================================================ -->
<div style="page-break-before: always;"></div>
<h1>RENTAL DETAILS</h1>
<!-- Customer Info and Rental Period Side by Side -->
<table style="width: 100%; margin-bottom: 10px;">
<tr>
<td style="width: 50%; vertical-align: top; padding-right: 10px;">
<table class="bordered" style="width: 100%;">
<tr>
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTER INFORMATION</th>
</tr>
<tr>
<td style="width: 35%; font-weight: bold; background-color: #f5f5f5;">Name</td>
<td><t t-esc="doc.partner_id.name"/></td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Address</td>
<td>
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
</td>
</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>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Order Ref</td>
<td><t t-esc="doc.name"/></td>
</tr>
</table>
</td>
<td style="width: 50%; vertical-align: top; padding-left: 10px;">
<table class="bordered" style="width: 100%;">
<tr>
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTAL PERIOD</th>
</tr>
<tr>
<td style="width: 40%; 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>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Return Date</td>
<td>
<t t-if="doc.rental_return_date">
<span t-field="doc.rental_return_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Duration</td>
<td>
<t t-if="doc.duration_days">
<span t-esc="doc.duration_days"/> Day<t t-if="doc.duration_days != 1">s</t>
<t t-if="doc.remaining_hours and doc.remaining_hours > 0">
, <t t-esc="doc.remaining_hours"/> Hr<t t-if="doc.remaining_hours != 1">s</t>
</t>
</t>
<t t-elif="doc.rental_start_date and doc.rental_return_date"><span>Less than 1 day</span></t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Total Amount</td>
<td><strong><span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></strong></td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Equipment / Order Lines Table -->
<table class="bordered" style="margin-bottom: 10px;">
<thead>
<tr>
<th style="width: 35%;">DESCRIPTION</th>
<th class="text-center" style="width: 8%;">QTY</th>
<th class="text-right" style="width: 17%;">UNIT PRICE</th>
<th class="text-right" style="width: 20%;">TAXES</th>
<th class="text-right" style="width: 20%;">TOTAL</th>
</tr>
</thead>
<tbody>
<t t-set="has_taxes" t-value="False"/>
<t t-foreach="doc.order_line" t-as="line">
<t t-if="not line.display_type">
<t t-if="line.tax_ids" t-set="has_taxes" t-value="True"/>
<tr>
<td>
<t t-esc="line.product_id.name"/>
<t t-if="line.is_security_deposit">
<br/><small style="color: #666; font-size: 7pt;">REFUNDABLE UPON RETURN IN GOOD &amp; CLEAN CONDITION</small>
</t>
</td>
<td class="text-center">
<span t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
<td class="text-right">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-right">
<t t-if="line.tax_ids">
<t t-esc="', '.join(line.tax_ids.mapped('name'))"/>
</t>
</td>
<td class="text-right">
<span t-field="line.price_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Totals - right-aligned bordered table (matching ADP style) -->
<div style="text-align: right; margin-bottom: 10px;">
<table class="bordered" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 140px; padding: 4px 8px;">Subtotal</td>
<td class="text-right" style="min-width: 100px; padding: 4px 8px;"><span t-field="doc.amount_untaxed"/></td>
</tr>
<tr>
<td style="padding: 4px 8px;">Taxes</td>
<td class="text-right" style="padding: 4px 8px;"><span t-field="doc.amount_tax"/></td>
</tr>
<tr style="background-color: #0066a1; color: white;">
<td style="padding: 4px 8px;"><strong>Total</strong></td>
<td class="text-right" style="padding: 4px 8px;"><strong><span t-field="doc.amount_total"/></strong></td>
</tr>
</table>
</div>
<!-- Credit Card Authorization - Compact -->
<div class="cc-section">
<div class="cc-title">CREDIT CARD PAYMENT AUTHORIZATION</div>
<table style="width: 100%; border: none;">
<tr>
<td style="width: 20%; padding: 5px 4px; border: none;"><strong>Card #:</strong></td>
<td style="padding: 5px 4px; border: none;">
<t t-if="doc.rental_payment_token_id">
<span style="font-size: 14px;">**** **** **** <t t-out="doc._get_card_last_four() or '****'">1234</t></span>
</t>
<t t-else="">
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
</t>
</td>
</tr>
<tr>
<td style="padding: 5px 4px; border: none;"><strong>Exp Date:</strong></td>
<td style="padding: 5px 4px; border: none;">
<span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 2px;">/</span>
<span class="cc-box"></span><span class="cc-box"></span>
<span style="margin-left: 20px;"><strong>CVV:</strong></span>
<span>***</span>
<t t-set="deposit_lines" t-value="doc.order_line.filtered(lambda l: l.is_security_deposit)"/>
<span style="margin-left: 20px;"><strong>Security Deposit:</strong>
<t t-if="deposit_lines">
$<t t-out="'%.2f' % sum(deposit_lines.mapped('price_unit'))">0.00</t>
</t>
<t t-else="">$___________</t>
</span>
</td>
</tr>
<tr>
<td style="padding: 5px 4px; border: none;"><strong>Cardholder:</strong></td>
<td style="padding: 5px 4px; border: none;">
<t t-if="doc.rental_agreement_signer_name">
<span t-out="doc.rental_agreement_signer_name">Name</span>
</t>
<t t-else="">
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%;"></div>
</t>
</td>
</tr>
<tr>
<td colspan="2" style="padding: 5px 4px; border: none;">
<strong>Billing Address (if different):</strong>
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%; margin-top: 4px;"></div>
</td>
</tr>
</table>
<div class="authorization-text">
<p>I authorize <t t-esc="company.name"/> to charge the credit card indicated in this authorization form according to the terms outlined above. I certify that I am an authorized user of this credit card and will not dispute the payment. By signing this form, I acknowledge that I have read the rental agreement and understand the terms and conditions. I understand that if the rented item is not returned on the agreed return date, additional charges will be incurred. *Payments for monthly rental items will be charged on the re-rental date until the item is returned.</p>
</div>
</div>
<!-- Signature Section - Compact -->
<div class="signature-section">
<div class="signature-box">
<table style="width: 100%; border: none;">
<tr>
<td style="width: 40%; padding: 5px; border: none;">
<div class="signature-label">FULL NAME (PRINT)</div>
<t t-if="doc.rental_agreement_signer_name">
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signer_name">Name</div>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
<td style="width: 40%; padding: 5px; border: none;">
<div class="signature-label">SIGNATURE</div>
<t t-if="doc.rental_agreement_signature">
<img t-att-src="'data:image/png;base64,' + doc.rental_agreement_signature.decode('utf-8') if doc.rental_agreement_signature else ''" style="max-height: 50px; max-width: 100%;"/>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
<td style="width: 20%; padding: 5px; border: none;">
<div class="signature-label">DATE</div>
<t t-if="doc.rental_agreement_signed_date">
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signed_date.strftime('%m/%d/%Y')">Date</div>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- Report Action -->
<record id="action_report_rental_agreement" model="ir.actions.report">
<field name="name">Rental Agreement</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_rental.report_rental_agreement</field>
<field name="report_file">fusion_rental.report_rental_agreement</field>
<field name="print_report_name">'Rental Agreement - %s' % object.name</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@@ -0,0 +1,9 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_renewal_log_user,rental.renewal.log.user,model_rental_renewal_log,sales_team.group_sale_salesman,1,0,0,0
access_renewal_log_manager,rental.renewal.log.manager,model_rental_renewal_log,fusion_rental.group_rental_manager,1,1,1,1
access_cancellation_request_user,rental.cancellation.request.user,model_rental_cancellation_request,sales_team.group_sale_salesman,1,1,1,0
access_cancellation_request_manager,rental.cancellation.request.manager,model_rental_cancellation_request,fusion_rental.group_rental_manager,1,1,1,1
access_manual_renewal_wizard_user,manual.renewal.wizard.user,model_manual_renewal_wizard,sales_team.group_sale_salesman,1,1,1,0
access_manual_renewal_wizard_manager,manual.renewal.wizard.manager,model_manual_renewal_wizard,fusion_rental.group_rental_manager,1,1,1,1
access_deposit_deduction_wizard_user,deposit.deduction.wizard.user,model_deposit_deduction_wizard,sales_team.group_sale_salesman,1,1,1,0
access_deposit_deduction_wizard_manager,deposit.deduction.wizard.manager,model_deposit_deduction_wizard,fusion_rental.group_rental_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_renewal_log_user rental.renewal.log.user model_rental_renewal_log sales_team.group_sale_salesman 1 0 0 0
3 access_renewal_log_manager rental.renewal.log.manager model_rental_renewal_log fusion_rental.group_rental_manager 1 1 1 1
4 access_cancellation_request_user rental.cancellation.request.user model_rental_cancellation_request sales_team.group_sale_salesman 1 1 1 0
5 access_cancellation_request_manager rental.cancellation.request.manager model_rental_cancellation_request fusion_rental.group_rental_manager 1 1 1 1
6 access_manual_renewal_wizard_user manual.renewal.wizard.user model_manual_renewal_wizard sales_team.group_sale_salesman 1 1 1 0
7 access_manual_renewal_wizard_manager manual.renewal.wizard.manager model_manual_renewal_wizard fusion_rental.group_rental_manager 1 1 1 1
8 access_deposit_deduction_wizard_user deposit.deduction.wizard.user model_deposit_deduction_wizard sales_team.group_sale_salesman 1 1 1 0
9 access_deposit_deduction_wizard_manager deposit.deduction.wizard.manager model_deposit_deduction_wizard fusion_rental.group_rental_manager 1 1 1 1

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_rental_enhancement" model="ir.module.category">
<field name="name">Rental Enhancement</field>
<field name="sequence">50</field>
</record>
<record id="res_groups_privilege_rental_enhancement" model="res.groups.privilege">
<field name="name">Rental Enhancement</field>
<field name="sequence">50</field>
<field name="category_id" ref="module_category_rental_enhancement"/>
</record>
<record id="group_rental_user" model="res.groups">
<field name="name">User</field>
<field name="sequence">10</field>
<field name="privilege_id" ref="res_groups_privilege_rental_enhancement"/>
<field name="implied_ids" eval="[(4, ref('base.group_user')), (4, ref('sales_team.group_sale_salesman'))]"/>
</record>
<record id="group_rental_manager" model="res.groups">
<field name="name">Administrator</field>
<field name="sequence">20</field>
<field name="privilege_id" ref="res_groups_privilege_rental_enhancement"/>
<field name="implied_ids" eval="[(4, ref('group_rental_user')), (4, ref('sales_team.group_sale_manager'))]"/>
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Cancellation Request List View -->
<record id="rental_cancellation_request_view_list" model="ir.ui.view">
<field name="name">rental.cancellation.request.list</field>
<field name="model">rental.cancellation.request</field>
<field name="arch" type="xml">
<list string="Cancellation Requests">
<field name="order_id"/>
<field name="partner_id"/>
<field name="request_date"/>
<field name="reason"/>
<field name="assigned_user_id"/>
<field name="state"
decoration-info="state == 'new'"
decoration-success="state in ('confirmed', 'completed')"
decoration-warning="state == 'pickup_scheduled'"
decoration-danger="state == 'rejected'"
widget="badge"/>
</list>
</field>
</record>
<!-- Cancellation Request Form View -->
<record id="rental_cancellation_request_view_form" model="ir.ui.view">
<field name="name">rental.cancellation.request.form</field>
<field name="model">rental.cancellation.request</field>
<field name="arch" type="xml">
<form string="Cancellation Request">
<header>
<button name="action_confirm"
type="object"
string="Confirm Cancellation"
class="btn-primary"
invisible="state != 'new'"/>
<button name="action_schedule_pickup"
type="object"
string="Schedule Pickup"
class="btn-primary"
invisible="state != 'confirmed'"/>
<button name="action_complete"
type="object"
string="Mark Completed"
class="btn-success"
invisible="state not in ('confirmed', 'pickup_scheduled')"/>
<button name="action_reject"
type="object"
string="Reject"
class="btn-danger"
invisible="state not in ('new', 'confirmed')"
confirm="Are you sure you want to reject this cancellation request?"/>
<field name="state" widget="statusbar"
statusbar_visible="new,confirmed,pickup_scheduled,completed"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="order_id" readonly="1"/>
</h1>
</div>
<group>
<group string="Request Details">
<field name="partner_id"/>
<field name="request_date"/>
<field name="requested_pickup_date"/>
</group>
<group string="Assignment">
<field name="assigned_user_id"/>
<field name="pickup_activity_id" readonly="1"/>
</group>
</group>
<group string="Reason">
<field name="reason" nolabel="1"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Cancellation Request Action -->
<record id="action_rental_cancellation_request" model="ir.actions.act_window">
<field name="name">Cancellation Requests</field>
<field name="res_model">rental.cancellation.request</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Override the core rental product action to add a hard domain filter.
The core action relies on search_default_filter_to_rent which can be
removed by the user. Adding a domain ensures only rental products
are ever shown when accessed from the Rental app.
-->
<record id="sale_renting.rental_product_template_action" model="ir.actions.act_window">
<field name="domain">[('rent_ok', '=', True)]</field>
</record>
<!-- Top-level menu under Rental app -->
<menuitem id="menu_rental_enhancement_root"
name="Rental Enhancement"
parent="sale_renting.rental_menu_root"
sequence="30"/>
<!-- Renewal History submenu -->
<menuitem id="menu_rental_renewal_log"
name="Renewal History"
parent="menu_rental_enhancement_root"
action="action_rental_renewal_log"
sequence="10"/>
<!-- Cancellation Requests submenu -->
<menuitem id="menu_rental_cancellation_request"
name="Cancellation Requests"
parent="menu_rental_enhancement_root"
action="action_rental_cancellation_request"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="portal_rental_inspection" model="ir.ui.view">
<field name="name">Rental Pickup Inspection</field>
<field name="type">qweb</field>
<field name="key">fusion_rental.portal_rental_inspection</field>
<field name="arch" type="xml">
<t t-call="web.frontend_layout">
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Rental Pickup Inspection</h3>
</div>
<div class="card-body">
<div class="mb-3">
<strong>Order:</strong> <t t-out="order.name or ''">SO001</t><br/>
<strong>Customer:</strong> <t t-out="order.partner_id.name or ''">Customer</t><br/>
<strong>Task:</strong> <t t-out="task.name or ''">Task</t>
</div>
<h5>Equipment Condition</h5>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="condition" id="cond_excellent" value="excellent"/>
<label class="form-check-label" for="cond_excellent">Excellent - No issues</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="condition" id="cond_good" value="good"/>
<label class="form-check-label" for="cond_good">Good - Minor wear</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="condition" id="cond_fair" value="fair"/>
<label class="form-check-label" for="cond_fair">Fair - Some issues noted</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="condition" id="cond_damaged" value="damaged"/>
<label class="form-check-label" for="cond_damaged">Damaged - Requires review</label>
</div>
</div>
<div class="mb-3">
<label for="inspection_notes" class="form-label">Notes / Damage Description</label>
<textarea id="inspection_notes" class="form-control" rows="4"
placeholder="Describe the condition, any damage, missing parts..."/>
</div>
<div class="mb-3">
<label class="form-label">Photos</label>
<input type="file" id="inspection_photos" class="form-control"
accept="image/*" multiple="multiple"/>
<div class="text-muted small mt-1">Upload photos of the equipment condition.</div>
</div>
<div id="inspection_error" class="alert alert-danger d-none"></div>
<div id="inspection_success" class="alert alert-success d-none"></div>
<button type="button" id="btn_submit_inspection" class="btn btn-primary btn-lg w-100"
t-att-data-task-id="task.id">
Submit Inspection
</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('btn_submit_inspection').addEventListener('click', async function() {
var btn = this;
btn.disabled = true;
btn.textContent = 'Submitting...';
var errDiv = document.getElementById('inspection_error');
var successDiv = document.getElementById('inspection_success');
errDiv.classList.add('d-none');
successDiv.classList.add('d-none');
var condition = document.querySelector('input[name="condition"]:checked');
if (!condition) {
errDiv.textContent = 'Please select an equipment condition.';
errDiv.classList.remove('d-none');
btn.disabled = false;
btn.textContent = 'Submit Inspection';
return;
}
var photoIds = [];
var files = document.getElementById('inspection_photos').files;
for (var i = 0; i &lt; files.length; i++) {
var reader = new FileReader();
var data = await new Promise(function(resolve) {
reader.onload = function(e) { resolve(e.target.result.split(',')[1]); };
reader.readAsDataURL(files[i]);
});
var resp = await fetch('/web/dataset/call_kw', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0', method: 'call', id: i + 1,
params: {
model: 'ir.attachment', method: 'create',
args: [{'name': files[i].name, 'type': 'binary', 'datas': data}],
kwargs: {},
}
})
});
var result = await resp.json();
if (result.result) photoIds.push(result.result);
}
var taskId = btn.dataset.taskId;
fetch('/my/technician/rental-inspection/' + taskId + '/submit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0', method: 'call', id: 99,
params: {
task_id: parseInt(taskId),
condition: condition.value,
notes: document.getElementById('inspection_notes').value,
photo_ids: photoIds,
}
})
}).then(function(r) { return r.json(); }).then(function(data) {
var res = data.result || data;
if (res.success) {
successDiv.textContent = res.message || 'Inspection saved!';
successDiv.classList.remove('d-none');
btn.textContent = 'Submitted!';
} else {
errDiv.textContent = res.error || 'An error occurred.';
errDiv.classList.remove('d-none');
btn.disabled = false;
btn.textContent = 'Submit Inspection';
}
});
});
});
</script>
</t>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Add Security Deposit fields to the Rental prices tab.
These fields are defined on product.template in fusion_claims but
only shown inside the Loaner Settings tab (invisible unless
x_fc_can_be_loaned). This view makes them accessible for ALL
rental products via the standard Rental prices page.
-->
<record id="product_template_form_rental_deposit" model="ir.ui.view">
<field name="name">product.template.form.rental.deposit</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="sale_renting.product_template_form_view_rental"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='pricing']//group[@name='extra_rental']" position="after">
<group string="Security Deposit" name="security_deposit">
<group>
<field name="x_fc_security_deposit_type"/>
<field name="x_fc_security_deposit_amount"
widget="monetary"
invisible="x_fc_security_deposit_type != 'fixed'"/>
<field name="x_fc_security_deposit_percent"
invisible="x_fc_security_deposit_type != 'percentage'"/>
</group>
<group>
<div class="text-muted" style="padding-top:8px;">
<p class="mb-1"><strong>Fixed:</strong> A flat dollar amount charged as deposit.</p>
<p class="mb-0"><strong>Percentage:</strong> Calculated from the rental line price.</p>
</div>
</group>
</group>
</xpath>
</field>
</record>
<!--
Show security deposit badge on sale order lines so users can
identify which line is the auto-generated deposit.
-->
<record id="sale_order_line_deposit_badge" model="ir.ui.view">
<field name="name">sale.order.line.deposit.badge</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale_renting.rental_order_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='order_line']//list//field[@name='qty_returned']" position="before">
<field name="is_security_deposit" column_invisible="not parent.is_rental_order"
widget="boolean_toggle" readonly="1" optional="show"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Renewal Log List View -->
<record id="rental_renewal_log_view_list" model="ir.ui.view">
<field name="name">rental.renewal.log.list</field>
<field name="model">rental.renewal.log</field>
<field name="arch" type="xml">
<list string="Renewal History" create="false">
<field name="order_id"/>
<field name="partner_id"/>
<field name="renewal_number"/>
<field name="renewal_type"/>
<field name="previous_start_date"/>
<field name="previous_return_date"/>
<field name="new_start_date"/>
<field name="new_return_date"/>
<field name="invoice_id"/>
<field name="payment_status"
decoration-success="payment_status == 'paid'"
decoration-danger="payment_status == 'failed'"
decoration-warning="payment_status == 'pending'"
widget="badge"/>
<field name="state"
decoration-success="state == 'done'"
decoration-danger="state == 'failed'"
decoration-info="state == 'draft'"
widget="badge"/>
</list>
</field>
</record>
<!-- Renewal Log Form View -->
<record id="rental_renewal_log_view_form" model="ir.ui.view">
<field name="name">rental.renewal.log.form</field>
<field name="model">rental.renewal.log</field>
<field name="arch" type="xml">
<form string="Renewal Log" create="false">
<sheet>
<div class="oe_title">
<h1>
<field name="order_id" readonly="1"/>
<span> - Renewal #</span>
<field name="renewal_number" readonly="1" class="oe_inline"/>
</h1>
</div>
<group>
<group string="Renewal Details">
<field name="renewal_type"/>
<field name="state"/>
<field name="partner_id"/>
</group>
<group string="Payment">
<field name="invoice_id"/>
<field name="payment_status"/>
<field name="payment_transaction_id"/>
</group>
</group>
<group>
<group string="Previous Period">
<field name="previous_start_date"/>
<field name="previous_return_date"/>
</group>
<group string="New Period">
<field name="new_start_date"/>
<field name="new_return_date"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Renewal Log Action -->
<record id="action_rental_renewal_log" model="ir.actions.act_window">
<field name="name">Renewal History</field>
<field name="res_model">rental.renewal.log</field>
<field name="view_mode">list,form</field>
<field name="context">{'create': False}</field>
</record>
</odoo>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_inherit_fusion_rental" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.fusion.rental</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="sale_renting.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//app[@name='sale_renting']" position="inside">
<block title="Rental Enhancement" name="rental_enhancement_settings">
<setting string="Google Review URL"
help="Google Review link shown in thank-you emails after rental close.">
<div class="content-group">
<div class="mt-2">
<field name="rental_google_review_url"
placeholder="https://g.page/r/YOUR_REVIEW_LINK/review"/>
</div>
<div class="text-muted mt-1">
For multiple locations, set the URL per warehouse in
Inventory &gt; Configuration &gt; Warehouses.
</div>
</div>
</setting>
<setting string="Security Deposit Hold Period"
help="How many days to hold the security deposit after product pickup before processing the refund.">
<div class="content-group">
<div class="row mt-2">
<label class="col-lg-3 o_light_label" for="rental_deposit_hold_days"/>
<field name="rental_deposit_hold_days" class="col-lg-2"/>
<span class="col-lg-4 text-muted">days after pickup</span>
</div>
</div>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,202 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_order_form_inherit_fusion_rental" model="ir.ui.view">
<field name="name">sale.order.form.inherit.fusion.rental</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale_renting.rental_order_form_view"/>
<field name="arch" type="xml">
<!-- Header buttons -->
<button name="action_open_pickup" position="before">
<button name="action_send_rental_agreement"
type="object"
class="btn-secondary"
string="Send Agreement"
data-hotkey="a"
invisible="not is_rental_order or state != 'sale' or rental_agreement_signed"
icon="fa-file-text-o"/>
<button name="action_manual_renewal"
type="object"
class="btn-secondary"
string="Renew Rental"
data-hotkey="r"
invisible="not is_rental_order or state != 'sale' or rental_status not in ('pickup', 'return')"
icon="fa-refresh"/>
<!-- Deposit buttons -->
<button name="action_create_deposit_invoice"
type="object"
class="btn-secondary"
string="Create Deposit Invoice"
invisible="not is_rental_order or state != 'sale' or rental_deposit_invoice_id"
icon="fa-file-text"/>
<button name="action_mark_deposit_collected"
type="object"
class="btn-secondary"
string="Mark Deposit Collected"
invisible="not is_rental_order or rental_deposit_status != 'pending'"
icon="fa-check-circle"/>
<button name="action_refund_deposit"
type="object"
class="btn-secondary"
string="Refund Deposit"
invisible="not is_rental_order or rental_deposit_status != 'collected'"
confirm="This will initiate the deposit refund hold period. Continue?"
icon="fa-undo"/>
<button name="action_force_refund_deposit"
type="object"
class="btn-secondary"
string="Process Refund Now"
invisible="not is_rental_order or rental_deposit_status != 'refund_hold'"
confirm="Skip the hold period and process the refund immediately?"
icon="fa-bolt"/>
<button name="action_deduct_deposit"
type="object"
class="btn-danger"
string="Deduct Deposit"
invisible="not is_rental_order or rental_deposit_status != 'collected'"
icon="fa-minus-circle"/>
<button name="action_close_rental"
type="object"
class="btn-warning"
string="Close Rental"
data-hotkey="x"
invisible="not is_rental_order or state != 'sale' or rental_closed or rental_deposit_status not in ('refunded', 'deducted', False)"
confirm="This will delete the stored card and send a thank-you email. Continue?"
icon="fa-power-off"/>
</button>
<!-- Rental fields -->
<field name="duration_days" position="after">
<!-- Renewal settings -->
<field name="rental_auto_renew" invisible="not is_rental_order"/>
<field name="rental_renewal_count" invisible="not is_rental_order or rental_renewal_count == 0"/>
<field name="rental_max_renewals" invisible="not is_rental_order or not rental_auto_renew"/>
<field name="rental_payment_token_id" invisible="not is_rental_order"/>
<field name="rental_next_renewal_date" invisible="not is_rental_order or not rental_auto_renew"/>
<field name="rental_reminder_sent" invisible="1"/>
<field name="rental_original_duration" invisible="1"/>
<field name="rental_agreement_token" invisible="1"/>
<!-- Agreement status -->
<field name="rental_agreement_signed" invisible="not is_rental_order"
widget="boolean_toggle" readonly="1"/>
<field name="rental_agreement_signer_name"
invisible="not is_rental_order or not rental_agreement_signed" readonly="1"/>
<field name="rental_agreement_signed_date"
invisible="not is_rental_order or not rental_agreement_signed" readonly="1"/>
<!-- Rental charges invoice -->
<field name="rental_charges_invoice_id"
invisible="not is_rental_order or not rental_charges_invoice_id" readonly="1"/>
<!-- Deposit status -->
<field name="rental_deposit_status" invisible="not is_rental_order or not rental_deposit_status"
decoration-success="rental_deposit_status == 'refunded'"
decoration-warning="rental_deposit_status in ('pending', 'refund_hold', 'collected')"
decoration-danger="rental_deposit_status == 'deducted'"
widget="badge"/>
<field name="rental_deposit_invoice_id"
invisible="not is_rental_order or not rental_deposit_invoice_id" readonly="1"/>
<!-- Inspection status -->
<field name="rental_inspection_status"
invisible="not is_rental_order or not rental_inspection_status"
decoration-success="rental_inspection_status == 'passed'"
decoration-danger="rental_inspection_status == 'flagged'"
widget="badge"/>
<!-- Purchase interest -->
<field name="rental_purchase_interest"
invisible="not is_rental_order or not rental_purchase_interest"
widget="boolean_toggle" readonly="1"/>
<field name="rental_purchase_coupon_id"
invisible="not is_rental_order or not rental_purchase_coupon_id" readonly="1"/>
<!-- Close status -->
<field name="rental_closed"
invisible="not is_rental_order or not rental_closed" readonly="1"/>
<field name="rental_marketing_email_sent" invisible="1"/>
</field>
<!-- Notebook pages -->
<xpath expr="//notebook" position="inside">
<page string="Renewal History"
name="renewal_history"
invisible="not is_rental_order or rental_renewal_count == 0">
<field name="rental_renewal_log_ids" readonly="1">
<list>
<field name="renewal_number"/>
<field name="renewal_type"/>
<field name="previous_start_date"/>
<field name="previous_return_date"/>
<field name="new_start_date"/>
<field name="new_return_date"/>
<field name="invoice_id"/>
<field name="payment_status"
decoration-success="payment_status == 'paid'"
decoration-danger="payment_status == 'failed'"
decoration-warning="payment_status == 'pending'"
widget="badge"/>
<field name="state"
decoration-success="state == 'done'"
decoration-danger="state == 'failed'"
widget="badge"/>
</list>
</field>
</page>
<page string="Cancellation Requests"
name="cancellation_requests"
invisible="not is_rental_order"
badge="rental_cancellation_request_ids">
<field name="rental_cancellation_request_ids">
<list>
<field name="request_date"/>
<field name="partner_id"/>
<field name="reason"/>
<field name="assigned_user_id"/>
<field name="state"
decoration-info="state == 'new'"
decoration-success="state in ('confirmed', 'completed')"
decoration-warning="state == 'pickup_scheduled'"
decoration-danger="state == 'rejected'"
widget="badge"/>
</list>
</field>
</page>
<page string="Inspection"
name="inspection"
invisible="not is_rental_order or not rental_inspection_status">
<group>
<group>
<field name="rental_inspection_status"/>
</group>
</group>
<group string="Inspection Notes">
<field name="rental_inspection_notes" nolabel="1"/>
</group>
<group string="Inspection Photos">
<field name="rental_inspection_photo_ids" widget="many2many_binary" nolabel="1"/>
</group>
</page>
<page string="Agreement"
name="agreement_tab"
invisible="not is_rental_order or not rental_agreement_signed">
<group>
<group string="Signature Details">
<field name="rental_agreement_signer_name" readonly="1"/>
<field name="rental_agreement_signed_date" readonly="1"/>
</group>
<group string="Signature">
<field name="rental_agreement_signature" widget="image" readonly="1"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import manual_renewal_wizard
from . import deposit_deduction_wizard

View File

@@ -0,0 +1,64 @@
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class DepositDeductionWizard(models.TransientModel):
_name = 'deposit.deduction.wizard'
_description = 'Security Deposit Deduction'
order_id = fields.Many2one(
'sale.order',
string="Rental Order",
required=True,
readonly=True,
)
deposit_total = fields.Float(
string="Deposit Amount",
readonly=True,
)
deduction_amount = fields.Float(
string="Deduction Amount",
required=True,
help="Amount to deduct from the security deposit for damages.",
)
reason = fields.Text(
string="Reason for Deduction",
required=True,
)
remaining_preview = fields.Float(
string="Remaining to Refund",
compute='_compute_remaining_preview',
)
overage_preview = fields.Float(
string="Additional Invoice Amount",
compute='_compute_remaining_preview',
help="Amount exceeding the deposit that will be invoiced to the customer.",
)
@api.depends('deposit_total', 'deduction_amount')
def _compute_remaining_preview(self):
for wizard in self:
diff = wizard.deposit_total - wizard.deduction_amount
if diff >= 0:
wizard.remaining_preview = diff
wizard.overage_preview = 0.0
else:
wizard.remaining_preview = 0.0
wizard.overage_preview = abs(diff)
def action_confirm_deduction(self):
self.ensure_one()
if self.deduction_amount <= 0:
raise UserError(_("Deduction amount must be greater than zero."))
order = self.order_id
order._deduct_security_deposit(self.deduction_amount)
order.message_post(body=_(
"Security deposit deduction of %s processed.\nReason: %s",
self.env['ir.qweb.field.monetary'].value_to_html(
self.deduction_amount,
{'display_currency': order.currency_id},
),
self.reason,
))
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="deposit_deduction_wizard_form" model="ir.ui.view">
<field name="name">deposit.deduction.wizard.form</field>
<field name="model">deposit.deduction.wizard</field>
<field name="arch" type="xml">
<form string="Deduct Security Deposit">
<group>
<group>
<field name="order_id"/>
<field name="deposit_total" widget="monetary"/>
</group>
<group>
<field name="deduction_amount" widget="monetary"/>
<field name="remaining_preview" widget="monetary"/>
<field name="overage_preview" widget="monetary"
decoration-danger="overage_preview > 0"/>
</group>
</group>
<group>
<field name="reason" placeholder="Describe the damages or reason for deduction..."/>
</group>
<footer>
<button name="action_confirm_deduction"
string="Confirm Deduction"
type="object"
class="btn-primary"
icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,101 @@
import logging
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ManualRenewalWizard(models.TransientModel):
_name = 'manual.renewal.wizard'
_description = 'Manual Rental Renewal Wizard'
order_id = fields.Many2one(
'sale.order',
string="Rental Order",
required=True,
readonly=True,
)
partner_id = fields.Many2one(
related='order_id.partner_id',
string="Customer",
)
current_start_date = fields.Datetime(
string="Current Start Date",
readonly=True,
)
current_return_date = fields.Datetime(
string="Current Return Date",
readonly=True,
)
new_start_date = fields.Datetime(
string="New Start Date",
required=True,
)
new_return_date = fields.Datetime(
string="New Return Date",
required=True,
)
amount_preview = fields.Float(
string="Estimated Renewal Amount",
compute='_compute_amount_preview',
)
@api.depends('order_id', 'new_start_date', 'new_return_date')
def _compute_amount_preview(self):
for wizard in self:
if wizard.order_id:
wizard.amount_preview = wizard.order_id._get_renewal_amount()
else:
wizard.amount_preview = 0.0
def action_confirm_renewal(self):
"""Confirm the manual renewal: extend dates, invoice, and collect payment."""
self.ensure_one()
order = self.order_id
if not order.is_rental_order:
raise UserError(_("This is not a rental order."))
if self.new_return_date <= self.new_start_date:
raise UserError(_("New return date must be after the new start date."))
old_start = order.rental_start_date
old_return = order.rental_return_date
order.write({
'rental_start_date': self.new_start_date,
'rental_return_date': self.new_return_date,
})
order._recompute_rental_prices()
invoice = order._create_renewal_invoice()
if invoice:
invoice.action_post()
renewal_log = self.env['rental.renewal.log'].create({
'order_id': order.id,
'renewal_number': order.rental_renewal_count + 1,
'previous_start_date': old_start,
'previous_return_date': old_return,
'new_start_date': self.new_start_date,
'new_return_date': self.new_return_date,
'invoice_id': invoice.id if invoice else False,
'renewal_type': 'manual',
'state': 'done',
'payment_status': 'pending',
})
order.write({
'rental_renewal_count': order.rental_renewal_count + 1,
'rental_reminder_sent': False,
})
order._send_renewal_confirmation_email(renewal_log, False)
if invoice:
inv = invoice.with_user(self.env.uid)
return inv.action_open_poynt_payment_wizard()
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Manual Renewal Wizard Form -->
<record id="manual_renewal_wizard_view_form" model="ir.ui.view">
<field name="name">manual.renewal.wizard.form</field>
<field name="model">manual.renewal.wizard</field>
<field name="arch" type="xml">
<form string="Renew Rental">
<group>
<field name="order_id" readonly="1"/>
<field name="partner_id" readonly="1"/>
</group>
<separator string="Current Rental Period"/>
<group>
<group>
<field name="current_start_date" readonly="1"/>
</group>
<group>
<field name="current_return_date" readonly="1"/>
</group>
</group>
<separator string="New Rental Period"/>
<group>
<group>
<field name="new_start_date"/>
</group>
<group>
<field name="new_return_date"/>
</group>
</group>
<group>
<field name="amount_preview" widget="monetary"/>
</group>
<footer>
<button name="action_confirm_renewal"
type="object"
string="Confirm Renewal &amp; Collect Payment"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>