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
This commit is contained in:
@@ -33,6 +33,17 @@ class FusionRentalController(http.Controller):
|
||||
{'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})
|
||||
@@ -41,7 +52,7 @@ class FusionRentalController(http.Controller):
|
||||
return request.render(
|
||||
'fusion_rental.cancellation_success_page',
|
||||
{
|
||||
'order': cancel_request.order_id,
|
||||
'order': order,
|
||||
'partner': cancel_request.partner_id,
|
||||
},
|
||||
)
|
||||
@@ -49,12 +60,51 @@ class FusionRentalController(http.Controller):
|
||||
return request.render(
|
||||
'fusion_rental.cancellation_form_page',
|
||||
{
|
||||
'order': cancel_request.order_id,
|
||||
'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
|
||||
# =================================================================
|
||||
@@ -79,6 +129,26 @@ class FusionRentalController(http.Controller):
|
||||
{'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',
|
||||
{
|
||||
@@ -86,6 +156,9 @@ class FusionRentalController(http.Controller):
|
||||
'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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -119,7 +192,10 @@ class FusionRentalController(http.Controller):
|
||||
pdf_content,
|
||||
headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition', f'inline; filename="Rental Agreement - {order.name}.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"'),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -130,7 +206,7 @@ class FusionRentalController(http.Controller):
|
||||
methods=['POST'],
|
||||
)
|
||||
def rental_agreement_sign(self, order_id, token, **kwargs):
|
||||
"""Process the agreement signing: save signature and tokenize card."""
|
||||
"""Process the agreement signing: save signature and tokenize card via nonce."""
|
||||
order = request.env['sale.order'].sudo().browse(order_id)
|
||||
if (
|
||||
not order.exists()
|
||||
@@ -141,33 +217,37 @@ class FusionRentalController(http.Controller):
|
||||
|
||||
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()
|
||||
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 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.'}
|
||||
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_card_via_poynt(
|
||||
order, card_number, exp_month, exp_year, cvv, cardholder_name,
|
||||
payment_token = self._tokenize_nonce_via_poynt(
|
||||
order, nonce, billing_postal_code,
|
||||
)
|
||||
except (UserError, Exception) as e:
|
||||
_logger.error("Card tokenization failed for %s: %s", order.name, e)
|
||||
_logger.error("Nonce tokenization failed for %s: %s", order.name, e)
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
order.write({
|
||||
@@ -176,21 +256,97 @@ class FusionRentalController(http.Controller):
|
||||
'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!',
|
||||
}
|
||||
|
||||
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."""
|
||||
@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'),
|
||||
@@ -198,67 +354,178 @@ class FusionRentalController(http.Controller):
|
||||
if not provider:
|
||||
raise UserError(_("Poynt payment provider is not configured."))
|
||||
|
||||
from odoo.addons.fusion_poynt import utils as poynt_utils
|
||||
result = provider._poynt_tokenize_nonce(nonce)
|
||||
|
||||
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_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', '')
|
||||
|
||||
payment_method = request.env['payment.method'].sudo().search(
|
||||
[('code', '=', 'card')], limit=1,
|
||||
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 = request.env['payment.method'].sudo().search(
|
||||
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 if payment_method else False,
|
||||
'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)
|
||||
# =================================================================
|
||||
|
||||
Reference in New Issue
Block a user