Files
gsinghpal 14fe9ab716 feat: hide authorizer for rental orders, auto-set sale type
Rental orders no longer show the "Authorizer Required?" question or
the Authorizer field. The sale type is automatically set to 'Rentals'
when creating or confirming a rental order. Validation logic also
skips authorizer checks for rental sale type.

Made-with: Cursor
2026-02-25 23:33:23 -05:00

573 lines
21 KiB
Python

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.")},
)
order = cancel_request.order_id
today = fields.Date.today()
if order.rental_next_renewal_date and order.rental_next_renewal_date <= today:
return request.render(
'fusion_rental.cancellation_invalid_page',
{'error': _(
"Cancellation is not allowed on or after the renewal date. "
"Please contact our office for assistance."
)},
)
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': order,
'partner': cancel_request.partner_id,
},
)
return request.render(
'fusion_rental.cancellation_form_page',
{
'order': order,
'partner': cancel_request.partner_id,
'token': token,
},
)
# =================================================================
# Portal: Confirm Quotation → Redirect to Rental Agreement
# =================================================================
@http.route(
'/rental/confirm-and-sign/<int:order_id>',
type='http',
auth='public',
website=True,
methods=['GET', 'POST'],
)
def rental_confirm_and_sign(self, order_id, access_token=None, **kwargs):
"""Confirm a rental quotation from the portal and redirect to the
rental agreement signing page.
This replaces Odoo's standard 'Sign & Pay' flow for rental orders.
Uses the sale order's access_token for authentication so customers
don't need a portal account.
"""
order = request.env['sale.order'].sudo().browse(order_id)
if (
not order.exists()
or not order.is_rental_order
or not access_token
or order.access_token != access_token
):
return request.redirect('/my/orders')
if order.state in ('draft', 'sent'):
order.action_confirm()
if not order.rental_agreement_token:
import uuid
order.rental_agreement_token = uuid.uuid4().hex
return request.redirect(
f'/rental/agreement/{order.id}/{order.rental_agreement_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.")},
)
google_api_key = request.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', ''
)
if not google_api_key:
google_api_key = request.env['ir.config_parameter'].sudo().get_param(
'fusion_rental.google_maps_api_key', ''
)
poynt_business_id = ''
poynt_application_id = ''
provider = request.env['payment.provider'].sudo().search([
('code', '=', 'poynt'),
('state', '!=', 'disabled'),
], limit=1)
if provider:
poynt_business_id = provider.poynt_business_id or ''
raw_app_id = provider.poynt_application_id or ''
from odoo.addons.fusion_poynt.utils import clean_application_id
poynt_application_id = clean_application_id(raw_app_id) or raw_app_id
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',
'google_api_key': google_api_key,
'poynt_business_id': poynt_business_id,
'poynt_application_id': poynt_application_id,
},
)
@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="{order.name} - {order.partner_id.name or ""}'
f' - Rental Agreement'
f'{" - Signed" if order.rental_agreement_signed else ""}.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 via nonce."""
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', '')
nonce = kwargs.get('nonce', '').strip()
billing_address = kwargs.get('billing_address', '').strip()
billing_city = kwargs.get('billing_city', '').strip()
billing_state = kwargs.get('billing_state', '').strip()
billing_postal_code = kwargs.get('billing_postal_code', '').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 nonce:
return {'success': False, 'error': 'Card authorization is required. Please fill in your card details.'}
if not billing_postal_code:
return {'success': False, 'error': 'Billing postal/zip code is required.'}
sig_binary = signature_data
if ',' in sig_binary:
sig_binary = sig_binary.split(',')[1]
full_billing = billing_address
if billing_city:
full_billing += f", {billing_city}"
if billing_state:
full_billing += f", {billing_state}"
try:
payment_token = self._tokenize_nonce_via_poynt(
order, nonce, billing_postal_code,
)
except (UserError, Exception) as e:
_logger.error("Nonce 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,
'rental_billing_address': full_billing,
'rental_billing_postal_code': billing_postal_code,
})
order.message_post(
body=_("Rental agreement signed by %s.", signer_name),
)
try:
order._generate_and_attach_signed_agreement()
except Exception as e:
_logger.error(
"Signed agreement PDF generation failed for %s: %s",
order.name, e,
)
try:
order._process_post_signing_payments()
except Exception as e:
_logger.error(
"Post-signing payment processing failed for %s: %s",
order.name, e,
)
return {
'success': True,
'message': 'Agreement signed successfully. Thank you!',
}
@http.route(
'/rental/agreement/<int:order_id>/<string:token>/decline',
type='json',
auth='public',
methods=['POST'],
)
def rental_agreement_decline(self, order_id, token, **kwargs):
"""Cancel/decline the rental order from the agreement 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 {'success': False, 'error': 'Invalid or expired agreement link.'}
try:
order.action_cancel()
order.message_post(
body=_("Rental order declined by the customer from the agreement page."),
)
except Exception as e:
_logger.error("Agreement decline failed for %s: %s", order.name, e)
return {'success': False, 'error': 'Unable to cancel the order. Please contact our office.'}
return {
'success': True,
'message': 'Your rental order has been cancelled. No charges have been applied.',
}
@http.route(
'/rental/agreement/<int:order_id>/<string:token>/thank-you',
type='http',
auth='public',
website=True,
methods=['GET'],
)
def rental_agreement_thank_you(self, order_id, token, **kwargs):
"""Render the thank-you page after successful agreement signing."""
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.")},
)
company = order.company_id or request.env.company
return request.render(
'fusion_rental.agreement_thank_you_page',
{
'order': order,
'partner': order.partner_id,
'company_phone': company.phone or '',
'company_email': company.email or '',
},
)
def _tokenize_nonce_via_poynt(self, order, nonce, billing_postal_code=''):
"""Exchange a Poynt Collect nonce for a payment JWT 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."))
result = provider._poynt_tokenize_nonce(nonce)
card_data = result.get('card', {})
card_id = card_data.get('id', '') or result.get('cardId', '')
last_four = card_data.get('numberLast4', '')
card_type = card_data.get('type', 'UNKNOWN')
payment_jwt = result.get('paymentToken', '')
avs_result = result.get('avsResult', {})
if avs_result and billing_postal_code:
avs_match = avs_result.get('postalCodeMatch', '')
if avs_match and avs_match not in ('MATCH', 'YES', 'Y'):
_logger.warning(
"AVS postal code mismatch for %s: expected=%s result=%s",
order.name, billing_postal_code, avs_match,
)
card_type_lower = card_type.lower() if card_type else ''
PaymentMethod = request.env['payment.method'].sudo().with_context(active_test=False)
payment_method = PaymentMethod.search(
[('code', '=', card_type_lower)], limit=1,
)
if not payment_method:
payment_method = PaymentMethod.search(
[('code', '=', 'card')], limit=1,
)
if not payment_method:
payment_method = PaymentMethod.search(
[('code', 'in', ('visa', 'mastercard'))], limit=1,
)
token = request.env['payment.token'].sudo().create({
'provider_id': provider.id,
'payment_method_id': payment_method.id,
'partner_id': order.partner_id.id,
'provider_ref': card_id or nonce[:40],
'poynt_card_id': card_id,
'poynt_payment_token': payment_jwt,
'payment_details': f"{card_type} ending in {last_four}",
})
return token
# =================================================================
# Card Reauthorization
# =================================================================
@http.route(
'/rental/reauthorize/<int:order_id>/<string:token>',
type='http',
auth='public',
website=True,
methods=['GET'],
)
def rental_reauthorize_page(self, order_id, token, **kwargs):
"""Render the card reauthorization portal page."""
order = request.env['sale.order'].sudo().browse(order_id)
if (
not order.exists()
or order.rental_agreement_token != token
or not order.is_rental_order
):
return request.render(
'fusion_rental.cancellation_invalid_page',
{'error': _("This link is invalid or has expired.")},
)
provider = request.env['payment.provider'].sudo().search([
('code', '=', 'poynt'),
('state', '!=', 'disabled'),
], limit=1)
return request.render(
'fusion_rental.card_reauthorization_page',
{
'order': order,
'partner': order.partner_id,
'poynt_business_id': provider.poynt_business_id if provider else '',
'poynt_application_id': provider.poynt_application_id if provider else '',
},
)
@http.route(
'/rental/reauthorize/<int:order_id>/<string:token>/submit',
type='json',
auth='public',
methods=['POST'],
)
def rental_reauthorize_submit(self, order_id, token, **kwargs):
"""Process the card reauthorization submission."""
order = request.env['sale.order'].sudo().browse(order_id)
if (
not order.exists()
or order.rental_agreement_token != token
or not order.is_rental_order
):
return {'success': False, 'error': 'Invalid or expired link.'}
cardholder_name = kwargs.get('cardholder_name', '').strip()
nonce = kwargs.get('nonce', '').strip()
billing_address = kwargs.get('billing_address', '').strip()
billing_city = kwargs.get('billing_city', '').strip()
billing_state = kwargs.get('billing_state', '').strip()
billing_postal_code = kwargs.get('billing_postal_code', '').strip()
if not cardholder_name:
return {'success': False, 'error': 'Cardholder name is required.'}
if not nonce:
return {'success': False, 'error': 'Card authorization is required.'}
if not billing_postal_code:
return {'success': False, 'error': 'Billing postal/zip code is required.'}
full_billing = billing_address
if billing_city:
full_billing += f", {billing_city}"
if billing_state:
full_billing += f", {billing_state}"
try:
payment_token = self._tokenize_nonce_via_poynt(
order, nonce, billing_postal_code,
)
except Exception as e:
_logger.error(
"Card reauthorization tokenization failed for %s: %s",
order.name, e,
)
return {'success': False, 'error': str(e)}
old_token = order.rental_payment_token_id
order.write({
'rental_payment_token_id': payment_token.id,
'rental_billing_address': full_billing,
'rental_billing_postal_code': billing_postal_code,
})
if old_token:
try:
old_token.active = False
except Exception:
pass
order.message_post(body=_(
"Card on file updated by %s. New card: %s",
cardholder_name,
payment_token.payment_details or 'Card',
))
try:
self._send_card_reauthorization_confirmation(order, payment_token, cardholder_name)
except Exception as e:
_logger.error(
"Card reauth confirmation email failed for %s: %s",
order.name, e,
)
return {
'success': True,
'message': 'Card authorized successfully. Thank you!',
}
def _send_card_reauthorization_confirmation(self, order, token, cardholder_name):
"""Send confirmation email with card authorization details."""
template = request.env.ref(
'fusion_rental.mail_template_rental_card_reauth_confirmation',
raise_if_not_found=False,
)
if template:
template.sudo().with_context(
cardholder_name=cardholder_name,
card_details=token.payment_details or 'Card',
).send_mail(order.id, force_send=True)
# =================================================================
# 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},
)