Compare commits
2 Commits
0e1aebe60b
...
34e5b46025
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e5b46025 | ||
|
|
e71bc503f9 |
@@ -2474,3 +2474,69 @@ class AuthorizerPortal(CustomerPortal):
|
||||
_logger.info(f"Attached video to assessment {assessment.reference}")
|
||||
except Exception as e:
|
||||
_logger.warning(f"Failed to attach video to assessment {assessment.reference}: {e}")
|
||||
|
||||
# =================================================================
|
||||
# RENTAL PICKUP INSPECTION (added by fusion_rental)
|
||||
# =================================================================
|
||||
|
||||
@http.route(
|
||||
'/my/technician/rental-inspection/<int:task_id>',
|
||||
type='http', auth='user', website=True,
|
||||
)
|
||||
def rental_inspection_page(self, task_id, **kw):
|
||||
"""Render the rental pickup inspection form for the technician."""
|
||||
user = request.env.user
|
||||
task = request.env['fusion.technician.task'].sudo().browse(task_id)
|
||||
|
||||
if (
|
||||
not task.exists()
|
||||
or task.technician_id.id != user.id
|
||||
or task.task_type != 'pickup'
|
||||
):
|
||||
return request.redirect('/my')
|
||||
|
||||
return request.render(
|
||||
'fusion_rental.portal_rental_inspection',
|
||||
{
|
||||
'task': task,
|
||||
'order': task.sale_order_id,
|
||||
'page_name': 'rental_inspection',
|
||||
},
|
||||
)
|
||||
|
||||
@http.route(
|
||||
'/my/technician/rental-inspection/<int:task_id>/submit',
|
||||
type='json', auth='user', methods=['POST'],
|
||||
)
|
||||
def rental_inspection_submit(self, task_id, **kwargs):
|
||||
"""Save the rental inspection results."""
|
||||
user = request.env.user
|
||||
task = request.env['fusion.technician.task'].sudo().browse(task_id)
|
||||
|
||||
if (
|
||||
not task.exists()
|
||||
or task.technician_id.id != user.id
|
||||
or task.task_type != 'pickup'
|
||||
):
|
||||
return {'success': False, 'error': 'Access denied.'}
|
||||
|
||||
condition = kwargs.get('condition', '')
|
||||
notes = kwargs.get('notes', '')
|
||||
photo_ids = kwargs.get('photo_ids', [])
|
||||
|
||||
if not condition:
|
||||
return {'success': False, 'error': 'Please select a condition.'}
|
||||
|
||||
vals = {
|
||||
'rental_inspection_condition': condition,
|
||||
'rental_inspection_notes': notes,
|
||||
'rental_inspection_completed': True,
|
||||
}
|
||||
if photo_ids:
|
||||
vals['rental_inspection_photo_ids'] = [(6, 0, photo_ids)]
|
||||
task.write(vals)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Inspection saved. You can now complete the task.',
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Claims',
|
||||
'version': '19.0.6.0.0',
|
||||
'version': '19.0.7.0.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||
'description': """
|
||||
|
||||
@@ -61,6 +61,61 @@ class ProductTemplate(models.Model):
|
||||
help='Rental price per month if loaner converts to rental',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# LOANER EQUIPMENT FIELDS
|
||||
# ==========================================================================
|
||||
|
||||
x_fc_equipment_type = fields.Selection([
|
||||
('type_1_walker', 'Type 1 Walker'),
|
||||
('type_2_mw', 'Type 2 MW'),
|
||||
('type_2_pw', 'Type 2 PW'),
|
||||
('type_2_walker', 'Type 2 Walker'),
|
||||
('type_3_mw', 'Type 3 MW'),
|
||||
('type_3_pw', 'Type 3 PW'),
|
||||
('type_3_walker', 'Type 3 Walker'),
|
||||
('type_4_mw', 'Type 4 MW'),
|
||||
('type_5_mw', 'Type 5 MW'),
|
||||
('ceiling_lift', 'Ceiling Lift'),
|
||||
('mobility_scooter', 'Mobility Scooter'),
|
||||
('patient_lift', 'Patient Lift'),
|
||||
('transport_wheelchair', 'Transport Wheelchair'),
|
||||
('standard_wheelchair', 'Standard Wheelchair'),
|
||||
('power_wheelchair', 'Power Wheelchair'),
|
||||
('cushion', 'Cushion'),
|
||||
('backrest', 'Backrest'),
|
||||
('stairlift', 'Stairlift'),
|
||||
('others', 'Others'),
|
||||
], string='Equipment Type')
|
||||
|
||||
x_fc_wheelchair_category = fields.Selection([
|
||||
('type_1', 'Type 1'),
|
||||
('type_2', 'Type 2'),
|
||||
('type_3', 'Type 3'),
|
||||
('type_4', 'Type 4'),
|
||||
('type_5', 'Type 5'),
|
||||
], string='Wheelchair Category')
|
||||
|
||||
x_fc_seat_width = fields.Char(string='Seat Width')
|
||||
x_fc_seat_depth = fields.Char(string='Seat Depth')
|
||||
x_fc_seat_height = fields.Char(string='Seat Height')
|
||||
|
||||
x_fc_storage_location = fields.Selection([
|
||||
('warehouse', 'Warehouse'),
|
||||
('westin_brampton', 'Westin Brampton'),
|
||||
('mobility_etobicoke', 'Mobility Etobicoke'),
|
||||
('scarborough_storage', 'Scarborough Storage'),
|
||||
('client_loaned', 'Client/Loaned'),
|
||||
('rented_out', 'Rented Out'),
|
||||
], string='Storage Location')
|
||||
|
||||
x_fc_listing_type = fields.Selection([
|
||||
('owned', 'Owned'),
|
||||
('borrowed', 'Borrowed'),
|
||||
], string='Listing Type')
|
||||
|
||||
x_fc_asset_number = fields.Char(string='Asset Number')
|
||||
x_fc_package_info = fields.Text(string='Package Information')
|
||||
|
||||
# ==========================================================================
|
||||
# COMPUTED FIELDS
|
||||
# ==========================================================================
|
||||
@@ -107,3 +162,25 @@ class ProductTemplate(models.Model):
|
||||
|
||||
return self.default_code or ''
|
||||
|
||||
# ==========================================================================
|
||||
# SECURITY DEPOSIT (added by fusion_rental)
|
||||
# ==========================================================================
|
||||
|
||||
x_fc_security_deposit_type = fields.Selection(
|
||||
[
|
||||
('fixed', 'Fixed Amount'),
|
||||
('percentage', 'Percentage of Rental Price'),
|
||||
],
|
||||
string='Security Deposit Type',
|
||||
help='How the security deposit is calculated for this rental product.',
|
||||
)
|
||||
x_fc_security_deposit_amount = fields.Float(
|
||||
string='Security Deposit Amount',
|
||||
digits='Product Price',
|
||||
help='Fixed dollar amount for the security deposit.',
|
||||
)
|
||||
x_fc_security_deposit_percent = fields.Float(
|
||||
string='Security Deposit (%)',
|
||||
help='Percentage of the rental line price to charge as deposit.',
|
||||
)
|
||||
|
||||
|
||||
@@ -388,6 +388,30 @@ class FusionTechnicianTask(models.Model):
|
||||
string='Notified At',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RENTAL INSPECTION (added by fusion_rental)
|
||||
# ------------------------------------------------------------------
|
||||
rental_inspection_condition = fields.Selection([
|
||||
('excellent', 'Excellent'),
|
||||
('good', 'Good'),
|
||||
('fair', 'Fair'),
|
||||
('damaged', 'Damaged'),
|
||||
], string='Inspection Condition')
|
||||
rental_inspection_notes = fields.Text(
|
||||
string='Inspection Notes',
|
||||
)
|
||||
rental_inspection_photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'technician_task_inspection_photo_rel',
|
||||
'task_id',
|
||||
'attachment_id',
|
||||
string='Inspection Photos',
|
||||
)
|
||||
rental_inspection_completed = fields.Boolean(
|
||||
string='Inspection Completed',
|
||||
default=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# COMPUTED FIELDS
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1608,22 +1632,37 @@ class FusionTechnicianTask(models.Model):
|
||||
'res_id': self.purchase_order_id.id,
|
||||
}
|
||||
|
||||
def _is_rental_pickup_task(self):
|
||||
"""Check if this is a pickup task for a rental order."""
|
||||
self.ensure_one()
|
||||
return (
|
||||
self.task_type == 'pickup'
|
||||
and self.sale_order_id
|
||||
and self.sale_order_id.is_rental_order
|
||||
)
|
||||
|
||||
def action_complete_task(self):
|
||||
"""Mark task as Completed."""
|
||||
for task in self:
|
||||
if task.status not in ('in_progress', 'en_route', 'scheduled'):
|
||||
raise UserError(_("Task must be in progress to complete."))
|
||||
|
||||
if task._is_rental_pickup_task() and not task.rental_inspection_completed:
|
||||
raise UserError(_(
|
||||
"Rental pickup tasks require a security inspection before "
|
||||
"completion. Please complete the inspection from the "
|
||||
"technician portal first."
|
||||
))
|
||||
|
||||
task.with_context(skip_travel_recalc=True).write({
|
||||
'status': 'completed',
|
||||
'completion_datetime': fields.Datetime.now(),
|
||||
})
|
||||
task._post_status_message('completed')
|
||||
# Post completion notes to linked order chatter
|
||||
if task.completion_notes and (task.sale_order_id or task.purchase_order_id):
|
||||
task._post_completion_to_linked_order()
|
||||
# Notify the person who scheduled the task
|
||||
task._notify_scheduler_on_completion()
|
||||
# Auto-advance ODSP status for delivery tasks
|
||||
|
||||
if (task.task_type == 'delivery'
|
||||
and task.sale_order_id
|
||||
and task.sale_order_id.x_fc_is_odsp_sale
|
||||
@@ -1633,6 +1672,44 @@ class FusionTechnicianTask(models.Model):
|
||||
"Delivery task completed by technician. Order marked as delivered.",
|
||||
)
|
||||
|
||||
if task._is_rental_pickup_task():
|
||||
task._apply_rental_inspection_results()
|
||||
|
||||
def _apply_rental_inspection_results(self):
|
||||
"""Write inspection results from the task back to the rental order."""
|
||||
self.ensure_one()
|
||||
order = self.sale_order_id
|
||||
if not order or not order.is_rental_order:
|
||||
return
|
||||
|
||||
inspection_status = 'passed'
|
||||
if self.rental_inspection_condition in ('fair', 'damaged'):
|
||||
inspection_status = 'flagged'
|
||||
|
||||
vals = {
|
||||
'rental_inspection_status': inspection_status,
|
||||
'rental_inspection_notes': self.rental_inspection_notes or '',
|
||||
}
|
||||
if self.rental_inspection_photo_ids:
|
||||
vals['rental_inspection_photo_ids'] = [(6, 0, self.rental_inspection_photo_ids.ids)]
|
||||
order.write(vals)
|
||||
|
||||
if inspection_status == 'passed':
|
||||
order._refund_security_deposit()
|
||||
elif inspection_status == 'flagged':
|
||||
order.activity_schedule(
|
||||
'mail.mail_activity_data_todo',
|
||||
date_deadline=fields.Date.today(),
|
||||
summary=_("Review rental inspection: %s", order.name),
|
||||
note=_(
|
||||
"Technician flagged rental pickup for %s. "
|
||||
"Condition: %s. Please review inspection photos and notes.",
|
||||
order.partner_id.name,
|
||||
self.rental_inspection_condition or 'Unknown',
|
||||
),
|
||||
user_id=order.user_id.id or self.env.uid,
|
||||
)
|
||||
|
||||
def action_cancel_task(self):
|
||||
"""Cancel the task. Sends cancellation email and reverts sale order if delivery."""
|
||||
for task in self:
|
||||
|
||||
@@ -270,13 +270,18 @@
|
||||
<tr>
|
||||
<td style="width: 20%; padding: 5px 4px; border: none;"><strong>Card #:</strong></td>
|
||||
<td style="padding: 5px 4px; border: none;">
|
||||
<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 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>
|
||||
@@ -286,14 +291,25 @@
|
||||
<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 class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
|
||||
<span style="margin-left: 20px;"><strong>Security Deposit:</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;">
|
||||
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%;"></div>
|
||||
<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>
|
||||
@@ -316,15 +332,24 @@
|
||||
<tr>
|
||||
<td style="width: 40%; padding: 5px; border: none;">
|
||||
<div class="signature-label">FULL NAME (PRINT)</div>
|
||||
<div class="signature-line"></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>
|
||||
<div class="signature-line"></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>
|
||||
<div class="signature-line"></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>
|
||||
|
||||
191
fusion_claims/scripts/cleanup_demo_pool.py
Normal file
191
fusion_claims/scripts/cleanup_demo_pool.py
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Pool Cleanup Script
|
||||
|
||||
Removes the Studio-created x_demo_pool_tracking model and all related
|
||||
database artifacts (tables, ir.model entries, fields, views, menus, etc.).
|
||||
|
||||
WARNING: Only run this AFTER verifying the import_demo_pool.py migration
|
||||
was successful and all data is correct in Loaner Products.
|
||||
|
||||
Run via Odoo shell:
|
||||
docker exec -i odoo-mobility-app odoo shell -d mobility < cleanup_demo_pool.py
|
||||
|
||||
Copyright 2024-2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
DEMO_POOL_MODELS = [
|
||||
'x_demo_pool_tracking',
|
||||
'x_demo_pool_tracking_line_4a032',
|
||||
'x_demo_pool_tracking_line_629cc',
|
||||
'x_demo_pool_tracking_line_b4ec9',
|
||||
'x_demo_pool_tracking_stage',
|
||||
'x_demo_pool_tracking_tag',
|
||||
]
|
||||
|
||||
DEMO_POOL_TABLES = DEMO_POOL_MODELS + [
|
||||
'x_demo_pool_tracking_tag_rel',
|
||||
]
|
||||
|
||||
PRODUCT_TEMPLATE_STUDIO_FIELDS_TO_REMOVE = [
|
||||
'x_studio_many2one_field_V8rnk',
|
||||
]
|
||||
|
||||
|
||||
def run_cleanup(env):
|
||||
cr = env.cr
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("Demo Pool Cleanup")
|
||||
print(f"{'='*60}")
|
||||
print()
|
||||
|
||||
# =====================================================================
|
||||
# Step 1: Remove ir.ui.menu entries referencing demo pool actions
|
||||
# =====================================================================
|
||||
print("[1/7] Removing menu items...")
|
||||
cr.execute("""
|
||||
DELETE FROM ir_ui_menu
|
||||
WHERE action IN (
|
||||
SELECT CONCAT('ir.actions.act_window,', id)
|
||||
FROM ir_act_window
|
||||
WHERE res_model IN %s
|
||||
)
|
||||
""", (tuple(DEMO_POOL_MODELS),))
|
||||
print(f" Removed {cr.rowcount} menu items")
|
||||
|
||||
# =====================================================================
|
||||
# Step 2: Remove ir.actions.act_window entries
|
||||
# =====================================================================
|
||||
print("[2/7] Removing window actions...")
|
||||
cr.execute("""
|
||||
DELETE FROM ir_act_window WHERE res_model IN %s
|
||||
""", (tuple(DEMO_POOL_MODELS),))
|
||||
print(f" Removed {cr.rowcount} window actions")
|
||||
|
||||
# =====================================================================
|
||||
# Step 3: Remove ir.ui.view entries
|
||||
# =====================================================================
|
||||
print("[3/7] Removing views...")
|
||||
cr.execute("""
|
||||
DELETE FROM ir_ui_view WHERE model IN %s
|
||||
""", (tuple(DEMO_POOL_MODELS),))
|
||||
print(f" Removed {cr.rowcount} views")
|
||||
|
||||
# =====================================================================
|
||||
# Step 4: Remove ir.model.access entries
|
||||
# =====================================================================
|
||||
print("[4/7] Removing access rules...")
|
||||
cr.execute("""
|
||||
SELECT id FROM ir_model WHERE model IN %s
|
||||
""", (tuple(DEMO_POOL_MODELS),))
|
||||
model_ids = [row[0] for row in cr.fetchall()]
|
||||
|
||||
if model_ids:
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model_access WHERE model_id IN %s
|
||||
""", (tuple(model_ids),))
|
||||
print(f" Removed {cr.rowcount} access rules")
|
||||
else:
|
||||
print(" No model IDs found (already cleaned?)")
|
||||
|
||||
# =====================================================================
|
||||
# Step 5: Remove ir.model.fields and ir.model entries
|
||||
# =====================================================================
|
||||
print("[5/7] Removing field definitions and model registry entries...")
|
||||
|
||||
if model_ids:
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model_fields_selection
|
||||
WHERE field_id IN (
|
||||
SELECT id FROM ir_model_fields WHERE model_id IN %s
|
||||
)
|
||||
""", (tuple(model_ids),))
|
||||
print(f" Removed {cr.rowcount} field selection values")
|
||||
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model_fields WHERE model_id IN %s
|
||||
""", (tuple(model_ids),))
|
||||
print(f" Removed {cr.rowcount} field definitions")
|
||||
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model_constraint WHERE model IN %s
|
||||
""", (tuple(model_ids),))
|
||||
print(f" Removed {cr.rowcount} constraints")
|
||||
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model_relation WHERE model IN %s
|
||||
""", (tuple(model_ids),))
|
||||
print(f" Removed {cr.rowcount} relations")
|
||||
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model WHERE id IN %s
|
||||
""", (tuple(model_ids),))
|
||||
print(f" Removed {cr.rowcount} model registry entries")
|
||||
|
||||
# =====================================================================
|
||||
# Step 6: Remove Studio field on product.template that linked to demo pool
|
||||
# =====================================================================
|
||||
print("[6/7] Removing demo pool Studio fields from product.template...")
|
||||
for field_name in PRODUCT_TEMPLATE_STUDIO_FIELDS_TO_REMOVE:
|
||||
cr.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'product_template' AND column_name = %s
|
||||
)
|
||||
""", (field_name,))
|
||||
exists = cr.fetchone()[0]
|
||||
if exists:
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model_fields_selection
|
||||
WHERE field_id IN (
|
||||
SELECT id FROM ir_model_fields
|
||||
WHERE model = 'product.template' AND name = %s
|
||||
)
|
||||
""", (field_name,))
|
||||
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model_fields
|
||||
WHERE model = 'product.template' AND name = %s
|
||||
""", (field_name,))
|
||||
|
||||
cr.execute(f'ALTER TABLE product_template DROP COLUMN IF EXISTS "{field_name}"')
|
||||
print(f" Removed {field_name} from product.template")
|
||||
else:
|
||||
print(f" {field_name} not found on product.template (already removed)")
|
||||
|
||||
# =====================================================================
|
||||
# Step 7: Drop the actual database tables
|
||||
# =====================================================================
|
||||
print("[7/7] Dropping demo pool tables...")
|
||||
for table in DEMO_POOL_TABLES:
|
||||
cr.execute(f"DROP TABLE IF EXISTS \"{table}\" CASCADE")
|
||||
print(f" Dropped {table}")
|
||||
|
||||
cr.commit()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("Cleanup Complete")
|
||||
print(f"{'='*60}")
|
||||
print()
|
||||
print("The following have been removed:")
|
||||
print(" - x_demo_pool_tracking model and all line/stage/tag tables")
|
||||
print(" - All related views, menus, actions, and access rules")
|
||||
print(" - x_studio_many2one_field_V8rnk from product.template")
|
||||
print()
|
||||
print("NOT touched:")
|
||||
print(" - x_studio_adp_price, x_studio_adp_sku on product.template")
|
||||
print(" - x_studio_msrp, x_studio_product_description_long on product.template")
|
||||
print(" - x_studio_product_details on product.template")
|
||||
print()
|
||||
print("Restart the Odoo server to clear the model registry cache.")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
run_cleanup(env)
|
||||
277
fusion_claims/scripts/import_demo_pool.py
Normal file
277
fusion_claims/scripts/import_demo_pool.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Pool to Loaner Products Migration Script
|
||||
|
||||
Reads all records from the Studio-created x_demo_pool_tracking table
|
||||
and creates proper product.template records with x_fc_can_be_loaned=True,
|
||||
plus stock.lot serial numbers where applicable.
|
||||
|
||||
Run via Odoo shell:
|
||||
docker exec -i odoo-mobility-app odoo shell -d mobility < import_demo_pool.py
|
||||
|
||||
Or from the host with the script mounted:
|
||||
docker exec -i odoo-mobility-app odoo shell -d mobility \
|
||||
< /mnt/extra-addons/fusion_claims/scripts/import_demo_pool.py
|
||||
|
||||
Copyright 2024-2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
EQUIPMENT_TYPE_MAP = {
|
||||
'Type 1 Walker': 'type_1_walker',
|
||||
'Type 2 MW': 'type_2_mw',
|
||||
'Type 2 PW': 'type_2_pw',
|
||||
'Type 2 Walker': 'type_2_walker',
|
||||
'Type 3 MW': 'type_3_mw',
|
||||
'Type 3 PW': 'type_3_pw',
|
||||
'Type 3 Walker': 'type_3_walker',
|
||||
'Type 4 MW': 'type_4_mw',
|
||||
'Type 5 MW': 'type_5_mw',
|
||||
'Ceiling Lift': 'ceiling_lift',
|
||||
'Mobility Scooter': 'mobility_scooter',
|
||||
'Patient Lift': 'patient_lift',
|
||||
'Transport Wheelchair': 'transport_wheelchair',
|
||||
'Wheelchair': 'standard_wheelchair',
|
||||
'Standard Wheelchair': 'standard_wheelchair',
|
||||
'Power Wheelchair': 'power_wheelchair',
|
||||
'Cushion': 'cushion',
|
||||
'Backrest': 'backrest',
|
||||
'Stairlift': 'stairlift',
|
||||
'Others': 'others',
|
||||
}
|
||||
|
||||
WHEELCHAIR_CATEGORY_MAP = {
|
||||
'Type 1': 'type_1',
|
||||
'Type 2': 'type_2',
|
||||
'Type 3': 'type_3',
|
||||
'Type 4': 'type_4',
|
||||
'Type 5': 'type_5',
|
||||
}
|
||||
|
||||
LOCATION_MAP = {
|
||||
'Warehouse': 'warehouse',
|
||||
'Westin Brampton': 'westin_brampton',
|
||||
'Mobility Etobicoke': 'mobility_etobicoke',
|
||||
'Scarborough Storage': 'scarborough_storage',
|
||||
'Client/Loaned': 'client_loaned',
|
||||
'Rented Out': 'rented_out',
|
||||
}
|
||||
|
||||
LISTING_TYPE_MAP = {
|
||||
'Owned': 'owned',
|
||||
'Borrowed': 'borrowed',
|
||||
}
|
||||
|
||||
SKIP_SERIALS = {'na', 'n/a', 'update', 'updated', ''}
|
||||
|
||||
|
||||
def extract_name(json_val):
|
||||
"""Extract English name from Odoo JSONB field."""
|
||||
if not json_val:
|
||||
return ''
|
||||
if isinstance(json_val, dict):
|
||||
return json_val.get('en_US', '') or ''
|
||||
if isinstance(json_val, str):
|
||||
try:
|
||||
parsed = json.loads(json_val)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed.get('en_US', '') or ''
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return json_val
|
||||
return str(json_val)
|
||||
|
||||
|
||||
def get_category_id(env, equipment_type_key):
|
||||
"""Map equipment type to an appropriate product category."""
|
||||
loaner_cat = env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False)
|
||||
wheelchair_cat = env.ref('fusion_claims.product_category_loaner_wheelchair', raise_if_not_found=False)
|
||||
powerchair_cat = env.ref('fusion_claims.product_category_loaner_powerchair', raise_if_not_found=False)
|
||||
rollator_cat = env.ref('fusion_claims.product_category_loaner_rollator', raise_if_not_found=False)
|
||||
|
||||
if not loaner_cat:
|
||||
return env['product.category'].search([], limit=1).id
|
||||
|
||||
wheelchair_types = {
|
||||
'type_2_mw', 'type_3_mw', 'type_4_mw', 'type_5_mw',
|
||||
'type_2_walker', 'type_3_walker', 'type_1_walker',
|
||||
'standard_wheelchair', 'transport_wheelchair',
|
||||
}
|
||||
powerchair_types = {'type_2_pw', 'type_3_pw', 'power_wheelchair'}
|
||||
rollator_types = set()
|
||||
|
||||
if equipment_type_key in powerchair_types and powerchair_cat:
|
||||
return powerchair_cat.id
|
||||
if equipment_type_key in wheelchair_types and wheelchair_cat:
|
||||
return wheelchair_cat.id
|
||||
if equipment_type_key in rollator_types and rollator_cat:
|
||||
return rollator_cat.id
|
||||
return loaner_cat.id
|
||||
|
||||
|
||||
def fetch_accessories(cr, demo_pool_id):
|
||||
"""Fetch accessory lines from x_demo_pool_tracking_line_b4ec9."""
|
||||
cr.execute("""
|
||||
SELECT x_name FROM x_demo_pool_tracking_line_b4ec9
|
||||
WHERE x_demo_pool_tracking_id = %s
|
||||
ORDER BY x_studio_sequence, id
|
||||
""", (demo_pool_id,))
|
||||
rows = cr.fetchall()
|
||||
accessories = []
|
||||
for row in rows:
|
||||
name = extract_name(row[0])
|
||||
if name:
|
||||
accessories.append(name)
|
||||
return accessories
|
||||
|
||||
|
||||
def run_import(env):
|
||||
cr = env.cr
|
||||
ProductTemplate = env['product.template']
|
||||
StockLot = env['stock.lot']
|
||||
|
||||
company = env.company
|
||||
|
||||
cr.execute("""
|
||||
SELECT
|
||||
id, x_name, x_studio_equipment_type, x_studio_wheelchair_categorytype,
|
||||
x_studio_serial_number, x_studio_seat_width, x_studio_seat_depth,
|
||||
x_studio_seat_height, x_studio_where_is_it_located, x_studio_listing_type,
|
||||
x_studio_asset_, x_studio_package_information, x_active,
|
||||
x_studio_value, x_studio_notes
|
||||
FROM x_demo_pool_tracking
|
||||
ORDER BY id
|
||||
""")
|
||||
rows = cr.fetchall()
|
||||
columns = [
|
||||
'id', 'x_name', 'equipment_type', 'wheelchair_category',
|
||||
'serial_number', 'seat_width', 'seat_depth',
|
||||
'seat_height', 'location', 'listing_type',
|
||||
'asset_number', 'package_info', 'active',
|
||||
'value', 'notes',
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
serial_count = 0
|
||||
skipped_serials = 0
|
||||
errors = []
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("Demo Pool to Loaner Products Migration")
|
||||
print(f"{'='*60}")
|
||||
print(f"Records found: {len(rows)}")
|
||||
print()
|
||||
|
||||
for row in rows:
|
||||
record = dict(zip(columns, row))
|
||||
demo_id = record['id']
|
||||
|
||||
try:
|
||||
name = extract_name(record['x_name'])
|
||||
if not name:
|
||||
errors.append(f"ID {demo_id}: empty name, skipped")
|
||||
continue
|
||||
|
||||
equipment_type_raw = record['equipment_type'] or ''
|
||||
equipment_type_key = EQUIPMENT_TYPE_MAP.get(equipment_type_raw, '')
|
||||
|
||||
wheelchair_cat_raw = record['wheelchair_category'] or ''
|
||||
wheelchair_cat_key = WHEELCHAIR_CATEGORY_MAP.get(wheelchair_cat_raw, '')
|
||||
|
||||
location_raw = record['location'] or ''
|
||||
location_key = LOCATION_MAP.get(location_raw, '')
|
||||
|
||||
listing_raw = record['listing_type'] or ''
|
||||
listing_key = LISTING_TYPE_MAP.get(listing_raw, '')
|
||||
|
||||
seat_width = (record['seat_width'] or '').strip()
|
||||
seat_depth = (record['seat_depth'] or '').strip()
|
||||
seat_height = (record['seat_height'] or '').strip()
|
||||
asset_number = (record['asset_number'] or '').strip()
|
||||
package_info = (record['package_info'] or '').strip()
|
||||
|
||||
accessories = fetch_accessories(cr, demo_id)
|
||||
if accessories:
|
||||
acc_text = '\n'.join(f'- {a}' for a in accessories)
|
||||
if package_info:
|
||||
package_info = f"{package_info}\n\nAccessories:\n{acc_text}"
|
||||
else:
|
||||
package_info = f"Accessories:\n{acc_text}"
|
||||
|
||||
is_active = bool(record['active'])
|
||||
categ_id = get_category_id(env, equipment_type_key)
|
||||
|
||||
product_vals = {
|
||||
'name': name,
|
||||
'type': 'consu',
|
||||
'tracking': 'serial',
|
||||
'sale_ok': False,
|
||||
'purchase_ok': False,
|
||||
'x_fc_can_be_loaned': True,
|
||||
'x_fc_loaner_period_days': 7,
|
||||
'x_fc_equipment_type': equipment_type_key or False,
|
||||
'x_fc_wheelchair_category': wheelchair_cat_key or False,
|
||||
'x_fc_seat_width': seat_width or False,
|
||||
'x_fc_seat_depth': seat_depth or False,
|
||||
'x_fc_seat_height': seat_height or False,
|
||||
'x_fc_storage_location': location_key or False,
|
||||
'x_fc_listing_type': listing_key or False,
|
||||
'x_fc_asset_number': asset_number or False,
|
||||
'x_fc_package_info': package_info or False,
|
||||
'categ_id': categ_id,
|
||||
'active': is_active,
|
||||
}
|
||||
|
||||
product = ProductTemplate.with_context(active_test=False).create(product_vals)
|
||||
created_count += 1
|
||||
|
||||
serial_raw = (record['serial_number'] or '').strip()
|
||||
if serial_raw.lower() not in SKIP_SERIALS:
|
||||
try:
|
||||
product_product = product.product_variant_id
|
||||
if product_product:
|
||||
lot = StockLot.create({
|
||||
'name': serial_raw,
|
||||
'product_id': product_product.id,
|
||||
'company_id': company.id,
|
||||
})
|
||||
serial_count += 1
|
||||
except Exception as e:
|
||||
skipped_serials += 1
|
||||
errors.append(f"ID {demo_id} ({name}): serial '{serial_raw}' failed: {e}")
|
||||
else:
|
||||
skipped_serials += 1
|
||||
|
||||
status = "ACTIVE" if is_active else "ARCHIVED"
|
||||
print(f" [{status}] {name} (demo #{demo_id}) -> product #{product.id}"
|
||||
f"{f' serial={serial_raw}' if serial_raw.lower() not in SKIP_SERIALS else ''}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"ID {demo_id}: {e}")
|
||||
print(f" ERROR: ID {demo_id}: {e}")
|
||||
|
||||
env.cr.commit()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("Migration Summary")
|
||||
print(f"{'='*60}")
|
||||
print(f"Products created: {created_count}")
|
||||
print(f"Serials created: {serial_count}")
|
||||
print(f"Serials skipped: {skipped_serials}")
|
||||
print(f"Errors: {len(errors)}")
|
||||
|
||||
if errors:
|
||||
print(f"\nErrors:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
|
||||
print(f"\nDone. Verify in Fusion Claims > Loaner Management > Loaner Products")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
run_import(env)
|
||||
@@ -154,14 +154,12 @@
|
||||
<button class="btn btn-sm d-flex align-items-center gap-1"
|
||||
t-att-class="state.showTasks ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
t-on-click="toggleTasks">
|
||||
<i class="fa fa-map-marker"/>Tasks
|
||||
<span class="badge text-bg-secondary ms-1" t-esc="state.taskCount"/>
|
||||
<i class="fa fa-map-marker"/>Tasks <t t-esc="state.taskCount"/>
|
||||
</button>
|
||||
<button class="btn btn-sm d-flex align-items-center gap-1"
|
||||
t-att-class="state.showTechnicians ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
t-on-click="toggleTechnicians">
|
||||
<i class="fa fa-user"/>Techs
|
||||
<span class="badge text-bg-secondary ms-1" t-esc="state.techCount"/>
|
||||
<i class="fa fa-user"/>Techs <t t-esc="state.techCount"/>
|
||||
</button>
|
||||
<span class="border-start mx-1" style="height:20px;"/>
|
||||
<span class="text-muted fw-bold" style="font-size:11px;">Pins:</span>
|
||||
|
||||
@@ -282,6 +282,60 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- LOANER PRODUCTS VIEWS -->
|
||||
<!-- ===================================================================== -->
|
||||
|
||||
<!-- Loaner Products List View -->
|
||||
<record id="view_fusion_loaner_products_list" model="ir.ui.view">
|
||||
<field name="name">product.template.loaner.list</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="x_fc_equipment_type" optional="show"/>
|
||||
<field name="x_fc_wheelchair_category" optional="show"/>
|
||||
<field name="x_fc_seat_width" optional="show"/>
|
||||
<field name="x_fc_seat_depth" optional="show"/>
|
||||
<field name="x_fc_seat_height" optional="hide"/>
|
||||
<field name="x_fc_storage_location" optional="show"/>
|
||||
<field name="x_fc_listing_type" optional="show"/>
|
||||
<field name="x_fc_asset_number" optional="hide"/>
|
||||
<field name="active" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Loaner Products Search View -->
|
||||
<record id="view_fusion_loaner_products_search" model="ir.ui.view">
|
||||
<field name="name">product.template.loaner.search</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Loaner Products">
|
||||
<field name="name"/>
|
||||
<field name="x_fc_equipment_type"/>
|
||||
<field name="x_fc_asset_number"/>
|
||||
<separator/>
|
||||
<filter string="Owned" name="filter_owned"
|
||||
domain="[('x_fc_listing_type', '=', 'owned')]"/>
|
||||
<filter string="Borrowed" name="filter_borrowed"
|
||||
domain="[('x_fc_listing_type', '=', 'borrowed')]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="filter_archived"
|
||||
domain="[('active', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter string="Equipment Type" name="group_equipment_type"
|
||||
context="{'group_by': 'x_fc_equipment_type'}"/>
|
||||
<filter string="Wheelchair Category" name="group_wheelchair_category"
|
||||
context="{'group_by': 'x_fc_wheelchair_category'}"/>
|
||||
<filter string="Storage Location" name="group_storage_location"
|
||||
context="{'group_by': 'x_fc_storage_location'}"/>
|
||||
<filter string="Listing Type" name="group_listing_type"
|
||||
context="{'group_by': 'x_fc_listing_type'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- ACTIONS -->
|
||||
<!-- ===================================================================== -->
|
||||
@@ -336,6 +390,8 @@
|
||||
<field name="name">Loaner Products</field>
|
||||
<field name="res_model">product.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" ref="view_fusion_loaner_products_list"/>
|
||||
<field name="search_view_id" ref="view_fusion_loaner_products_search"/>
|
||||
<field name="domain">[('x_fc_can_be_loaned', '=', True)]</field>
|
||||
<field name="context">{'default_x_fc_can_be_loaned': True, 'default_sale_ok': False, 'default_purchase_ok': False, 'default_rent_ok': True}</field>
|
||||
<field name="help" type="html">
|
||||
@@ -430,9 +486,40 @@
|
||||
<field name="x_fc_rental_price_monthly"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Equipment Details">
|
||||
<field name="x_fc_equipment_type"/>
|
||||
<field name="x_fc_wheelchair_category"/>
|
||||
<field name="x_fc_listing_type"/>
|
||||
<field name="x_fc_asset_number"/>
|
||||
</group>
|
||||
<group string="Dimensions">
|
||||
<field name="x_fc_seat_width"/>
|
||||
<field name="x_fc_seat_depth"/>
|
||||
<field name="x_fc_seat_height"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Location">
|
||||
<field name="x_fc_storage_location"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Package Information">
|
||||
<field name="x_fc_package_info" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<group string="Security Deposit">
|
||||
<group>
|
||||
<field name="x_fc_security_deposit_type"/>
|
||||
<field name="x_fc_security_deposit_amount"
|
||||
invisible="x_fc_security_deposit_type != 'fixed'"/>
|
||||
<field name="x_fc_security_deposit_percent"
|
||||
invisible="x_fc_security_deposit_type != 'percentage'"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -232,6 +232,22 @@
|
||||
<field name="voice_note_transcription"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Rental Inspection" name="rental_inspection"
|
||||
invisible="task_type != 'pickup'">
|
||||
<group>
|
||||
<group string="Condition">
|
||||
<field name="rental_inspection_condition"/>
|
||||
<field name="rental_inspection_completed"/>
|
||||
</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>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_fax_base,fusion.fax.base.read,model_fusion_fax,base.group_user,1,0,0,0
|
||||
access_fusion_fax_user,fusion.fax.user,model_fusion_fax,group_fax_user,1,1,1,0
|
||||
access_fusion_fax_manager,fusion.fax.manager,model_fusion_fax,group_fax_manager,1,1,1,1
|
||||
access_fusion_send_fax_wizard_user,fusion.send.fax.wizard.user,model_fusion_faxes_send_fax_wizard,group_fax_user,1,1,1,1
|
||||
access_fusion_send_fax_wizard_line_user,fusion.send.fax.wizard.line.user,model_fusion_faxes_send_fax_wizard_line,group_fax_user,1,1,1,1
|
||||
access_fusion_fax_document_base,fusion.fax.document.base.read,model_fusion_fax_document,base.group_user,1,0,0,0
|
||||
access_fusion_fax_document_user,fusion.fax.document.user,model_fusion_fax_document,group_fax_user,1,1,1,0
|
||||
access_fusion_fax_document_manager,fusion.fax.document.manager,model_fusion_fax_document,group_fax_manager,1,1,1,1
|
||||
access_fusion_fax_dashboard_user,fusion.fax.dashboard.user,model_fusion_fax_dashboard,group_fax_user,1,1,1,1
|
||||
|
||||
|
@@ -1,22 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Module category (appears in Settings > Users) -->
|
||||
<record id="module_category_fusion_faxes" model="ir.module.category">
|
||||
<field name="name">Fusion Faxes</field>
|
||||
<field name="sequence">47</field>
|
||||
</record>
|
||||
|
||||
<!-- Privilege (Odoo 19 user settings dropdown) -->
|
||||
<record id="res_groups_privilege_fusion_faxes" model="res.groups.privilege">
|
||||
<field name="name">Fusion Faxes</field>
|
||||
<field name="sequence">47</field>
|
||||
<field name="category_id" ref="module_category_fusion_faxes"/>
|
||||
</record>
|
||||
|
||||
<!-- User group: can send faxes and view own fax history -->
|
||||
<record id="group_fax_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_faxes"/>
|
||||
</record>
|
||||
|
||||
<!-- Manager group: can view all faxes and configure settings -->
|
||||
<record id="group_fax_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fax_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_faxes"/>
|
||||
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- User group: can send faxes and view own fax history -->
|
||||
<record id="group_fax_user" model="res.groups">
|
||||
<field name="name">Fusion Faxes / User</field>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<!-- Base users can read fax records (needed for One2many computed fields on sale.order etc.) -->
|
||||
<record id="rule_fax_base_read" model="ir.rule">
|
||||
<field name="name">Fax: all internal users read-only</field>
|
||||
<field name="model_id" ref="model_fusion_fax"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Manager group: can view all faxes and configure settings -->
|
||||
<record id="group_fax_manager" model="res.groups">
|
||||
<field name="name">Fusion Faxes / Manager</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fax_user'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Record rules -->
|
||||
|
||||
<!-- Users see only their own faxes -->
|
||||
<!-- Fax users see only their own faxes (full CRUD minus unlink) -->
|
||||
<record id="rule_fax_user_own" model="ir.rule">
|
||||
<field name="name">Fax: user sees own faxes</field>
|
||||
<field name="model_id" ref="model_fusion_fax"/>
|
||||
|
||||
@@ -8,16 +8,17 @@
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Smart button for fax count -->
|
||||
<!-- Smart button for fax count (fax users only) -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_faxes" type="object"
|
||||
class="oe_stat_button" icon="fa-fax"
|
||||
invisible="x_ff_fax_count == 0">
|
||||
invisible="x_ff_fax_count == 0"
|
||||
groups="fusion_faxes.group_fax_user">
|
||||
<field name="x_ff_fax_count" widget="statinfo" string="Faxes"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Send Fax header button -->
|
||||
<!-- Send Fax header button (fax users only) -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_send_fax" string="Send Fax"
|
||||
type="object" class="btn-secondary"
|
||||
|
||||
@@ -232,10 +232,11 @@
|
||||
<field name="search_view_id" ref="view_fusion_fax_search"/>
|
||||
</record>
|
||||
|
||||
<!-- Top-level Faxes menu -->
|
||||
<!-- Top-level Faxes menu (fax users only) -->
|
||||
<menuitem id="menu_fusion_faxes_root"
|
||||
name="Faxes"
|
||||
web_icon="fusion_faxes,static/description/icon.png"
|
||||
groups="group_fax_user"
|
||||
sequence="45"/>
|
||||
|
||||
<menuitem id="menu_fusion_fax_list"
|
||||
|
||||
@@ -8,28 +8,31 @@
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Add fax number on its own row below phone -->
|
||||
<!-- Add fax number on its own row below phone (fax users only) -->
|
||||
<xpath expr="//field[@name='phone']/.." position="after">
|
||||
<div class="d-flex align-items-baseline w-md-50">
|
||||
<div class="d-flex align-items-baseline w-md-50"
|
||||
groups="fusion_faxes.group_fax_user">
|
||||
<i class="fa fa-fw me-1 fa-fax text-primary" title="Fax"/>
|
||||
<field name="x_ff_fax_number" class="w-100" widget="phone"
|
||||
placeholder="Fax number..."/>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- Add smart button for fax count -->
|
||||
<!-- Add smart button for fax count (fax users only) -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_faxes" type="object"
|
||||
class="oe_stat_button" icon="fa-fax"
|
||||
invisible="x_ff_fax_count == 0">
|
||||
invisible="x_ff_fax_count == 0"
|
||||
groups="fusion_faxes.group_fax_user">
|
||||
<field name="x_ff_fax_count" widget="statinfo" string="Faxes"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Add Fax History tab -->
|
||||
<!-- Add Fax History tab (fax users only) -->
|
||||
<xpath expr="//page[@name='internal_notes']" position="after">
|
||||
<page string="Fax History" name="fax_history"
|
||||
invisible="x_ff_fax_count == 0">
|
||||
invisible="x_ff_fax_count == 0"
|
||||
groups="fusion_faxes.group_fax_user">
|
||||
<field name="x_ff_fax_ids" readonly="1">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
|
||||
@@ -8,16 +8,17 @@
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Smart button for fax count -->
|
||||
<!-- Smart button for fax count (fax users only) -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_faxes" type="object"
|
||||
class="oe_stat_button" icon="fa-fax"
|
||||
invisible="x_ff_fax_count == 0">
|
||||
invisible="x_ff_fax_count == 0"
|
||||
groups="fusion_faxes.group_fax_user">
|
||||
<field name="x_ff_fax_count" widget="statinfo" string="Faxes"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Send Fax header button -->
|
||||
<!-- Send Fax header button (fax users only) -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_send_fax" string="Send Fax"
|
||||
type="object" class="btn-secondary"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
|
||||
@@ -7,15 +7,24 @@
|
||||
'sequence': 360,
|
||||
'summary': "GoDaddy Poynt payment processing for cloud and terminal payments.",
|
||||
'description': " ",
|
||||
'depends': ['payment', 'account_payment'],
|
||||
'depends': ['payment', 'account_payment', 'sale'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
|
||||
'report/poynt_receipt_report.xml',
|
||||
'report/poynt_receipt_templates.xml',
|
||||
|
||||
'views/payment_provider_views.xml',
|
||||
'views/payment_transaction_views.xml',
|
||||
'views/payment_poynt_templates.xml',
|
||||
'views/poynt_terminal_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'wizard/poynt_payment_wizard_views.xml',
|
||||
'wizard/poynt_refund_wizard_views.xml',
|
||||
|
||||
'data/payment_provider_data.xml',
|
||||
'data/poynt_receipt_email_template.xml',
|
||||
],
|
||||
'post_init_hook': 'post_init_hook',
|
||||
'uninstall_hook': 'uninstall_hook',
|
||||
@@ -23,6 +32,10 @@
|
||||
'web.assets_frontend': [
|
||||
'fusion_poynt/static/src/interactions/**/*',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'fusion_poynt/static/src/js/**/*',
|
||||
'fusion_poynt/static/src/xml/**/*',
|
||||
],
|
||||
},
|
||||
'author': 'Fusion Apps',
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<field name="inline_form_view_id" ref="inline_form"/>
|
||||
<field name="allow_tokenization">True</field>
|
||||
<field name="state">disabled</field>
|
||||
<field name="image_128" type="base64" file="fusion_poynt/static/src/img/poynt_logo.png"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
97
fusion_poynt/data/poynt_receipt_email_template.xml
Normal file
97
fusion_poynt/data/poynt_receipt_email_template.xml
Normal file
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="mail_template_poynt_receipt" model="mail.template">
|
||||
<field name="name">Poynt: Payment/Refund Receipt</field>
|
||||
<field name="model_id" ref="payment.model_payment_transaction"/>
|
||||
<field name="subject"><![CDATA[{{ object.company_id.name }} - <t t-if="object.operation == 'refund' or object.amount < 0">Refund Receipt</t><t t-else="">Payment Receipt</t> {{ object.reference or 'n/a' }}]]></field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||
<field name="report_template_ids"
|
||||
eval="[(4, ref('fusion_poynt.action_report_poynt_receipt'))]"/>
|
||||
<field name="body_html"><![CDATA[
|
||||
<t t-set="is_refund" t-value="object.operation == 'refund' or object.amount < 0"/>
|
||||
<t t-set="accent" t-value="'#dc3545' if is_refund else '#28a745'"/>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
|
||||
<div t-attf-style="height:4px;background-color:{{ accent }};"></div>
|
||||
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
|
||||
<p t-attf-style="color:{{ accent }};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;">
|
||||
<t t-if="is_refund">Refund Receipt</t>
|
||||
<t t-else="">Payment Receipt</t>
|
||||
</h2>
|
||||
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||
<t t-if="is_refund">
|
||||
Your refund for <strong style="color:#2d3748;"><t t-out="object.reference"/></strong> has been processed.
|
||||
</t>
|
||||
<t t-else="">
|
||||
Your payment for <strong style="color:#2d3748;"><t t-out="object.reference"/></strong> has been processed successfully.
|
||||
</t>
|
||||
</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;">Transaction Details</td></tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Type</td>
|
||||
<td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #f0f0f0;">
|
||||
<t t-if="is_refund"><strong style="color:#dc3545;">Refund</strong></t>
|
||||
<t t-else=""><strong style="color:#28a745;">Payment</strong></t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Reference</td>
|
||||
<td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.reference"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td>
|
||||
<td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.create_date.strftime('%B %d, %Y')"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Status</td>
|
||||
<td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #f0f0f0;">
|
||||
<t t-if="is_refund"><strong style="color:#dc3545;">Refunded</strong></t>
|
||||
<t t-else=""><strong style="color:#28a745;">Confirmed</strong></t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Amount</td>
|
||||
<td t-attf-style="padding:10px 14px;color:{{ accent }};font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;">
|
||||
<t t-if="is_refund">- </t><t t-out="object.currency_id.symbol"/><t t-out="'%.2f' % abs(object.amount)"/> <t t-out="object.currency_id.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> Transaction Receipt (PDF)</p>
|
||||
</div>
|
||||
|
||||
<div t-attf-style="border-left:3px solid {{ accent }};padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">
|
||||
<t t-if="is_refund">
|
||||
The refund will appear on your card within 3-5 business days. If you have any questions, please do not hesitate to contact us.
|
||||
</t>
|
||||
<t t-else="">
|
||||
Thank you for your payment. If you have any questions about this transaction, please do not hesitate to contact us.
|
||||
</t>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<t t-if="object.company_id.phone or object.company_id.email">
|
||||
<p style="margin:0 0 4px 0;font-size:13px;color:#718096;">
|
||||
<t t-if="object.company_id.phone"><t t-out="object.company_id.phone"/></t>
|
||||
<t t-if="object.company_id.phone and object.company_id.email"> | </t>
|
||||
<t t-if="object.company_id.email"><t t-out="object.company_id.email"/></t>
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
]]></field>
|
||||
<field name="lang">{{ object.partner_id.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,6 +1,8 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import account_move
|
||||
from . import payment_provider
|
||||
from . import payment_token
|
||||
from . import payment_transaction
|
||||
from . import poynt_terminal
|
||||
from . import sale_order
|
||||
|
||||
203
fusion_poynt/models/account_move.py
Normal file
203
fusion_poynt/models/account_move.py
Normal file
@@ -0,0 +1,203 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
poynt_refunded = fields.Boolean(
|
||||
string="Refunded via Poynt",
|
||||
readonly=True,
|
||||
copy=False,
|
||||
default=False,
|
||||
)
|
||||
poynt_refund_count = fields.Integer(
|
||||
string="Poynt Refund Count",
|
||||
compute='_compute_poynt_refund_count',
|
||||
)
|
||||
has_poynt_receipt = fields.Boolean(
|
||||
string="Has Poynt Receipt",
|
||||
compute='_compute_has_poynt_receipt',
|
||||
)
|
||||
|
||||
@api.depends('reversal_move_ids')
|
||||
def _compute_poynt_refund_count(self):
|
||||
for move in self:
|
||||
if move.move_type == 'out_invoice':
|
||||
move.poynt_refund_count = len(move.reversal_move_ids.filtered(
|
||||
lambda r: r.poynt_refunded
|
||||
))
|
||||
else:
|
||||
move.poynt_refund_count = 0
|
||||
|
||||
def _compute_has_poynt_receipt(self):
|
||||
for move in self:
|
||||
move.has_poynt_receipt = bool(move._get_poynt_transaction_for_receipt())
|
||||
|
||||
def action_view_poynt_refunds(self):
|
||||
"""Open the credit notes linked to this invoice that were refunded via Poynt."""
|
||||
self.ensure_one()
|
||||
refund_moves = self.reversal_move_ids.filtered(lambda r: r.poynt_refunded)
|
||||
action = {
|
||||
'name': _("Poynt Refunds"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', refund_moves.ids)],
|
||||
'context': {'default_move_type': 'out_refund'},
|
||||
}
|
||||
if len(refund_moves) == 1:
|
||||
action['view_mode'] = 'form'
|
||||
action['res_id'] = refund_moves.id
|
||||
return action
|
||||
|
||||
def _get_poynt_transaction_for_receipt(self):
|
||||
"""Find the Poynt transaction linked to this invoice or credit note."""
|
||||
self.ensure_one()
|
||||
domain = [
|
||||
('provider_id.code', '=', 'poynt'),
|
||||
('poynt_transaction_id', '!=', False),
|
||||
('state', '=', 'done'),
|
||||
]
|
||||
if self.move_type == 'out_invoice':
|
||||
domain.append(('invoice_ids', 'in', self.ids))
|
||||
elif self.move_type == 'out_refund':
|
||||
domain += [
|
||||
('operation', '=', 'refund'),
|
||||
('invoice_ids', 'in', self.ids),
|
||||
]
|
||||
else:
|
||||
return self.env['payment.transaction']
|
||||
|
||||
return self.env['payment.transaction'].sudo().search(
|
||||
domain, order='id desc', limit=1,
|
||||
)
|
||||
|
||||
def action_resend_poynt_receipt(self):
|
||||
"""Resend the Poynt payment/refund receipt email to the customer."""
|
||||
self.ensure_one()
|
||||
tx = self._get_poynt_transaction_for_receipt()
|
||||
if not tx:
|
||||
raise UserError(_(
|
||||
"No completed Poynt transaction found for this document."
|
||||
))
|
||||
|
||||
template = self.env.ref(
|
||||
'fusion_poynt.mail_template_poynt_receipt',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not template:
|
||||
raise UserError(_("Receipt email template not found."))
|
||||
|
||||
report = self.env.ref(
|
||||
'fusion_poynt.action_report_poynt_receipt',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
attachment_ids = []
|
||||
if report:
|
||||
pdf_content, _content_type = report.sudo()._render_qweb_pdf(
|
||||
report_ref='fusion_poynt.action_report_poynt_receipt',
|
||||
res_ids=tx.ids,
|
||||
)
|
||||
prefix = "Refund_Receipt" if self.move_type == 'out_refund' else "Payment_Receipt"
|
||||
filename = f"{prefix}_{tx.reference}.pdf"
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(pdf_content),
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
attachment_ids = [att.id]
|
||||
|
||||
template.send_mail(tx.id, force_send=True)
|
||||
|
||||
is_refund = self.move_type == 'out_refund'
|
||||
label = _("Refund") if is_refund else _("Payment")
|
||||
self.message_post(
|
||||
body=_(
|
||||
"%(label)s receipt resent to %(email)s.",
|
||||
label=label,
|
||||
email=tx.partner_id.email,
|
||||
),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
attachment_ids=attachment_ids,
|
||||
)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Receipt Sent"),
|
||||
'message': _("The receipt has been sent to %s.",
|
||||
tx.partner_id.email),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
def action_open_poynt_payment_wizard(self):
|
||||
"""Open the Poynt payment collection wizard for this invoice."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _("Collect Poynt Payment"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'poynt.payment.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'active_model': 'account.move',
|
||||
'active_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_open_poynt_refund_wizard(self):
|
||||
"""Open the Poynt refund wizard for this credit note."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _("Refund via Poynt"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'poynt.refund.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'active_model': 'account.move',
|
||||
'active_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def _get_original_poynt_transaction(self):
|
||||
"""Find the Poynt payment transaction from the reversed invoice.
|
||||
|
||||
For credit notes created via the "Reverse" action, the
|
||||
``reversed_entry_id`` links back to the original invoice.
|
||||
We look for a confirmed Poynt transaction on that invoice.
|
||||
"""
|
||||
self.ensure_one()
|
||||
origin_invoice = self.reversed_entry_id
|
||||
if not origin_invoice:
|
||||
return self.env['payment.transaction']
|
||||
|
||||
tx = self.env['payment.transaction'].sudo().search([
|
||||
('invoice_ids', 'in', origin_invoice.ids),
|
||||
('state', '=', 'done'),
|
||||
('provider_id.code', '=', 'poynt'),
|
||||
('poynt_transaction_id', '!=', False),
|
||||
('poynt_voided', '=', False),
|
||||
], order='id desc', limit=1)
|
||||
|
||||
if not tx:
|
||||
tx = self.env['payment.transaction'].sudo().search([
|
||||
('invoice_ids', 'in', origin_invoice.ids),
|
||||
('state', 'in', ('done', 'cancel')),
|
||||
('provider_id.code', '=', 'poynt'),
|
||||
('poynt_transaction_id', '!=', False),
|
||||
], order='id desc', limit=1)
|
||||
|
||||
return tx
|
||||
@@ -53,6 +53,13 @@ class PaymentProvider(models.Model):
|
||||
copy=False,
|
||||
groups='base.group_system',
|
||||
)
|
||||
poynt_default_terminal_id = fields.Many2one(
|
||||
'poynt.terminal',
|
||||
string="Default Terminal",
|
||||
help="The default Poynt terminal used for in-store payment collection. "
|
||||
"Staff can override this per transaction.",
|
||||
domain="[('provider_id', '=', id), ('active', '=', True)]",
|
||||
)
|
||||
|
||||
# Cached access token fields (not visible in UI)
|
||||
_poynt_access_token = fields.Char(
|
||||
@@ -121,10 +128,16 @@ class PaymentProvider(models.Model):
|
||||
},
|
||||
headers={
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Api-Version': const.API_VERSION,
|
||||
'api-version': const.API_VERSION,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
_logger.error(
|
||||
"Poynt token request failed (HTTP %s): %s",
|
||||
response.status_code, response.text[:1000],
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
@@ -210,24 +223,32 @@ class PaymentProvider(models.Model):
|
||||
_("Poynt authentication expired. Please retry.")
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
if response.status_code in (202, 204):
|
||||
return {}
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
if response.status_code < 400:
|
||||
return {}
|
||||
_logger.error("Poynt returned non-JSON response: %s", response.text[:500])
|
||||
raise ValidationError(_("Poynt returned an invalid response."))
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = result.get('message', result.get('developerMessage', 'Unknown error'))
|
||||
dev_msg = result.get('developerMessage', '')
|
||||
_logger.error(
|
||||
"Poynt API error %s: %s (request_id=%s)",
|
||||
"Poynt API error %s: %s (request_id=%s)\n"
|
||||
" URL: %s %s\n Payload: %s\n Response: %s\n Developer: %s",
|
||||
response.status_code, error_msg, request_id,
|
||||
method, url,
|
||||
json.dumps(payload)[:2000] if payload else 'None',
|
||||
response.text[:2000],
|
||||
dev_msg,
|
||||
)
|
||||
raise ValidationError(
|
||||
_("Poynt API error (%(code)s): %(msg)s",
|
||||
code=response.status_code, msg=error_msg)
|
||||
code=response.status_code, msg=dev_msg or error_msg)
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -252,6 +273,7 @@ class PaymentProvider(models.Model):
|
||||
minor_amount = poynt_utils.format_poynt_amount(amount, currency) if amount else 0
|
||||
|
||||
inline_form_values = {
|
||||
'provider_id': self.id,
|
||||
'business_id': self.poynt_business_id,
|
||||
'application_id': self.poynt_application_id,
|
||||
'currency_name': currency.name if currency else 'USD',
|
||||
@@ -283,7 +305,11 @@ class PaymentProvider(models.Model):
|
||||
# === ACTION METHODS === #
|
||||
|
||||
def action_poynt_test_connection(self):
|
||||
"""Test the connection to Poynt by fetching business info.
|
||||
"""Test the connection to Poynt by authenticating and fetching business info.
|
||||
|
||||
If the Business ID appears to be a numeric MID rather than a UUID,
|
||||
the method attempts to decode the access token to find the real
|
||||
business UUID and auto-correct it.
|
||||
|
||||
:return: A notification action with the result.
|
||||
:rtype: dict
|
||||
@@ -291,11 +317,25 @@ class PaymentProvider(models.Model):
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
access_token = self._poynt_get_access_token()
|
||||
|
||||
business_id = self.poynt_business_id
|
||||
is_uuid = business_id and '-' in business_id and len(business_id) > 30
|
||||
if not is_uuid and business_id:
|
||||
resolved_biz_id = self._poynt_resolve_business_id(access_token)
|
||||
if resolved_biz_id:
|
||||
self.sudo().write({'poynt_business_id': resolved_biz_id})
|
||||
_logger.info(
|
||||
"Auto-corrected Business ID from MID %s to UUID %s",
|
||||
business_id, resolved_biz_id,
|
||||
)
|
||||
|
||||
result = self._poynt_make_request('GET', '')
|
||||
business_name = result.get('legalName', result.get('doingBusinessAs', 'Unknown'))
|
||||
message = _(
|
||||
"Connection successful. Business: %(name)s",
|
||||
"Connection successful. Business: %(name)s (ID: %(bid)s)",
|
||||
name=business_name,
|
||||
bid=self.poynt_business_id,
|
||||
)
|
||||
notification_type = 'success'
|
||||
except (ValidationError, UserError) as e:
|
||||
@@ -312,30 +352,71 @@ class PaymentProvider(models.Model):
|
||||
},
|
||||
}
|
||||
|
||||
def _poynt_resolve_business_id(self, access_token):
|
||||
"""Try to extract the real business UUID from the access token JWT.
|
||||
|
||||
The Poynt access token contains a 'poynt.biz' claim with the
|
||||
merchant's business UUID when the token was obtained via merchant
|
||||
authorization. For app-level tokens, we fall back to the 'poynt.org'
|
||||
claim or attempt a direct API lookup.
|
||||
|
||||
:param str access_token: The current access token.
|
||||
:return: The business UUID, or False if it cannot be resolved.
|
||||
:rtype: str or bool
|
||||
"""
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
claims = pyjwt.decode(access_token, options={"verify_signature": False})
|
||||
biz_id = claims.get('poynt.biz') or claims.get('poynt.org')
|
||||
if biz_id:
|
||||
return biz_id
|
||||
except Exception as e:
|
||||
_logger.warning("Could not decode access token to find business ID: %s", e)
|
||||
return False
|
||||
|
||||
def action_poynt_fetch_terminals(self):
|
||||
"""Fetch terminal devices from Poynt and create/update local records.
|
||||
|
||||
Uses GET /businesses/{id}/stores which returns stores with their
|
||||
nested storeDevices arrays. The main business endpoint does not
|
||||
include stores in its response.
|
||||
|
||||
:return: A notification action with the result.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
store_id = self.poynt_store_id
|
||||
if store_id:
|
||||
endpoint = f'stores/{store_id}/storeDevices'
|
||||
else:
|
||||
endpoint = 'storeDevices'
|
||||
result = self._poynt_make_request('GET', 'stores')
|
||||
stores = result if isinstance(result, list) else result.get('stores', [])
|
||||
|
||||
result = self._poynt_make_request('GET', endpoint)
|
||||
devices = result if isinstance(result, list) else result.get('storeDevices', [])
|
||||
all_devices = []
|
||||
for store in stores:
|
||||
store_id = store.get('id', '')
|
||||
for device in store.get('storeDevices', []):
|
||||
device['_store_id'] = store_id
|
||||
device['_store_name'] = store.get('displayName', store.get('name', ''))
|
||||
all_devices.append(device)
|
||||
|
||||
if not all_devices:
|
||||
return self._poynt_notification(
|
||||
_("No terminal devices found for this business."), 'warning'
|
||||
)
|
||||
|
||||
terminal_model = self.env['poynt.terminal']
|
||||
created = 0
|
||||
updated = 0
|
||||
|
||||
for device in devices:
|
||||
first_store_id = None
|
||||
for device in all_devices:
|
||||
device_id = device.get('deviceId', '')
|
||||
if not device_id:
|
||||
continue
|
||||
|
||||
store_id = device.get('_store_id', '')
|
||||
if not first_store_id and store_id:
|
||||
first_store_id = store_id
|
||||
|
||||
existing = terminal_model.search([
|
||||
('device_id', '=', device_id),
|
||||
('provider_id', '=', self.id),
|
||||
@@ -347,7 +428,7 @@ class PaymentProvider(models.Model):
|
||||
'serial_number': device.get('serialNumber', ''),
|
||||
'provider_id': self.id,
|
||||
'status': 'online' if device.get('status') == 'ACTIVATED' else 'offline',
|
||||
'store_id_poynt': device.get('storeId', ''),
|
||||
'store_id_poynt': store_id,
|
||||
}
|
||||
|
||||
if existing:
|
||||
@@ -357,15 +438,53 @@ class PaymentProvider(models.Model):
|
||||
terminal_model.create(vals)
|
||||
created += 1
|
||||
|
||||
if first_store_id and not self.poynt_store_id:
|
||||
self.sudo().write({'poynt_store_id': first_store_id})
|
||||
_logger.info("Auto-filled Store ID: %s", first_store_id)
|
||||
|
||||
message = _(
|
||||
"Terminals synced: %(created)s created, %(updated)s updated.",
|
||||
created=created, updated=updated,
|
||||
)
|
||||
notification_type = 'success'
|
||||
return self._poynt_notification(message, 'success')
|
||||
except (ValidationError, UserError) as e:
|
||||
message = _("Failed to fetch terminals: %(error)s", error=str(e))
|
||||
notification_type = 'danger'
|
||||
return self._poynt_notification(
|
||||
_("Failed to fetch terminals: %(error)s", error=str(e)), 'danger'
|
||||
)
|
||||
|
||||
def _poynt_fetch_receipt(self, transaction_id):
|
||||
"""Fetch the rendered receipt from Poynt for a given transaction.
|
||||
|
||||
Calls GET /businesses/{businessId}/transactions/{transactionId}/receipt
|
||||
which returns a TransactionReceipt with a ``data`` field containing
|
||||
the rendered receipt content (HTML or text).
|
||||
|
||||
:param str transaction_id: The Poynt transaction UUID.
|
||||
:return: The receipt content string, or None on failure.
|
||||
:rtype: str | None
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not transaction_id:
|
||||
return None
|
||||
try:
|
||||
result = self._poynt_make_request(
|
||||
'GET', f'transactions/{transaction_id}/receipt',
|
||||
)
|
||||
return result.get('data') or None
|
||||
except (ValidationError, Exception):
|
||||
_logger.debug(
|
||||
"Could not fetch Poynt receipt for transaction %s", transaction_id,
|
||||
)
|
||||
return None
|
||||
|
||||
def _poynt_notification(self, message, notification_type='info'):
|
||||
"""Return a display_notification action.
|
||||
|
||||
:param str message: The notification message.
|
||||
:param str notification_type: One of 'success', 'warning', 'danger', 'info'.
|
||||
:return: The notification action dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from werkzeug.urls import url_encode
|
||||
@@ -28,6 +30,23 @@ class PaymentTransaction(models.Model):
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
poynt_receipt_data = fields.Text(
|
||||
string="Poynt Receipt Data",
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help="JSON blob with receipt-relevant fields captured at payment time.",
|
||||
)
|
||||
poynt_voided = fields.Boolean(
|
||||
string="Voided on Poynt",
|
||||
readonly=True,
|
||||
copy=False,
|
||||
default=False,
|
||||
)
|
||||
poynt_void_date = fields.Datetime(
|
||||
string="Voided On",
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# === BUSINESS METHODS - PAYMENT FLOW === #
|
||||
|
||||
@@ -87,6 +106,8 @@ class PaymentTransaction(models.Model):
|
||||
try:
|
||||
order_payload = poynt_utils.build_order_payload(
|
||||
self.reference, self.amount, self.currency_id,
|
||||
business_id=self.provider_id.poynt_business_id,
|
||||
store_id=self.provider_id.poynt_store_id or '',
|
||||
)
|
||||
order_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'orders', payload=order_payload,
|
||||
@@ -138,6 +159,8 @@ class PaymentTransaction(models.Model):
|
||||
|
||||
order_payload = poynt_utils.build_order_payload(
|
||||
self.reference, self.amount, self.currency_id,
|
||||
business_id=self.provider_id.poynt_business_id,
|
||||
store_id=self.provider_id.poynt_store_id or '',
|
||||
)
|
||||
order_result = self.provider_id._poynt_make_request(
|
||||
'POST', 'orders', payload=order_payload,
|
||||
@@ -173,7 +196,12 @@ class PaymentTransaction(models.Model):
|
||||
self._set_error(str(e))
|
||||
|
||||
def _send_refund_request(self):
|
||||
"""Override of `payment` to send a refund request to Poynt."""
|
||||
"""Override of `payment` to send a refund request to Poynt.
|
||||
|
||||
For captured/settled transactions (SALE), we look up the
|
||||
CAPTURE child via HATEOAS links and use that as ``parentId``.
|
||||
The ``fundingSource`` is required per Poynt docs.
|
||||
"""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_refund_request()
|
||||
|
||||
@@ -181,10 +209,33 @@ class PaymentTransaction(models.Model):
|
||||
refund_amount = abs(self.amount)
|
||||
minor_amount = poynt_utils.format_poynt_amount(refund_amount, self.currency_id)
|
||||
|
||||
parent_txn_id = source_tx.poynt_transaction_id or source_tx.provider_reference
|
||||
|
||||
try:
|
||||
txn_data = self.provider_id._poynt_make_request(
|
||||
'GET', f'transactions/{parent_txn_id}',
|
||||
)
|
||||
for link in txn_data.get('links', []):
|
||||
if link.get('rel') == 'CAPTURE' and link.get('href'):
|
||||
parent_txn_id = link['href']
|
||||
_logger.info(
|
||||
"Refund: using captureId %s instead of original %s",
|
||||
parent_txn_id, source_tx.poynt_transaction_id,
|
||||
)
|
||||
break
|
||||
except (ValidationError, Exception):
|
||||
_logger.debug(
|
||||
"Could not fetch parent txn %s, using original ID",
|
||||
parent_txn_id,
|
||||
)
|
||||
|
||||
try:
|
||||
refund_payload = {
|
||||
'action': 'REFUND',
|
||||
'parentId': source_tx.provider_reference,
|
||||
'parentId': parent_txn_id,
|
||||
'fundingSource': {
|
||||
'type': 'CREDIT_DEBIT',
|
||||
},
|
||||
'amounts': {
|
||||
'transactionAmount': minor_amount,
|
||||
'orderAmount': minor_amount,
|
||||
@@ -250,35 +301,175 @@ class PaymentTransaction(models.Model):
|
||||
self._set_error(str(e))
|
||||
|
||||
def _send_void_request(self):
|
||||
"""Override of `payment` to send a void request to Poynt."""
|
||||
"""Override of `payment` to send a void request to Poynt.
|
||||
|
||||
Uses ``POST /transactions/{transactionId}/void`` -- the dedicated
|
||||
void endpoint.
|
||||
"""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._send_void_request()
|
||||
|
||||
source_tx = self.source_transaction_id
|
||||
txn_id = source_tx.provider_reference or source_tx.poynt_transaction_id
|
||||
|
||||
try:
|
||||
void_payload = {
|
||||
'action': 'VOID',
|
||||
'parentId': source_tx.provider_reference,
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
},
|
||||
}
|
||||
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=void_payload,
|
||||
'POST', f'transactions/{txn_id}/void',
|
||||
)
|
||||
|
||||
payment_data = {
|
||||
'reference': self.reference,
|
||||
'poynt_transaction_id': result.get('id'),
|
||||
'poynt_transaction_id': result.get('id', txn_id),
|
||||
'poynt_status': result.get('status', 'VOIDED'),
|
||||
}
|
||||
self._process('poynt', payment_data)
|
||||
except ValidationError as e:
|
||||
self._set_error(str(e))
|
||||
|
||||
# === ACTION METHODS - VOID === #
|
||||
|
||||
def action_poynt_void(self):
|
||||
"""Void a confirmed Poynt transaction (same-day, before settlement).
|
||||
|
||||
For SALE transactions Poynt creates an AUTHORIZE + CAPTURE pair.
|
||||
Voiding the AUTHORIZE after capture is rejected by the processor,
|
||||
so we first fetch the transaction, look for a CAPTURE child via
|
||||
the HATEOAS ``links``, and void that instead.
|
||||
|
||||
On success the linked ``account.payment`` is cancelled (reversing
|
||||
invoice reconciliation) and the Odoo transaction is set to
|
||||
cancelled. No credit note is created because funds were never
|
||||
settled.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.provider_code != 'poynt':
|
||||
raise ValidationError(_("This action is only available for Poynt transactions."))
|
||||
if self.state != 'done':
|
||||
raise ValidationError(_("Only confirmed transactions can be voided."))
|
||||
txn_id = self.poynt_transaction_id or self.provider_reference
|
||||
if not txn_id:
|
||||
raise ValidationError(_("No Poynt transaction ID found."))
|
||||
|
||||
existing_refund = self.env['payment.transaction'].sudo().search([
|
||||
('source_transaction_id', '=', self.id),
|
||||
('operation', '=', 'refund'),
|
||||
('state', '=', 'done'),
|
||||
], limit=1)
|
||||
if existing_refund:
|
||||
raise ValidationError(_(
|
||||
"This transaction has already been refunded "
|
||||
"(%(ref)s). A voided transaction and a refund would "
|
||||
"result in a double reversal.",
|
||||
ref=existing_refund.reference,
|
||||
))
|
||||
|
||||
provider = self.sudo().provider_id
|
||||
|
||||
txn_data = provider._poynt_make_request('GET', f'transactions/{txn_id}')
|
||||
|
||||
poynt_status = txn_data.get('status', '')
|
||||
if poynt_status in ('REFUNDED', 'VOIDED') or txn_data.get('voided'):
|
||||
raise ValidationError(_(
|
||||
"This transaction has already been %(status)s on Poynt. "
|
||||
"It cannot be voided again.",
|
||||
status=poynt_status.lower() if poynt_status else 'voided',
|
||||
))
|
||||
|
||||
void_target_id = txn_id
|
||||
|
||||
for link in txn_data.get('links', []):
|
||||
child_id = link.get('href', '')
|
||||
child_rel = link.get('rel', '')
|
||||
if not child_id:
|
||||
continue
|
||||
if child_rel == 'CAPTURE':
|
||||
void_target_id = child_id
|
||||
try:
|
||||
child_data = provider._poynt_make_request(
|
||||
'GET', f'transactions/{child_id}',
|
||||
)
|
||||
child_status = child_data.get('status', '')
|
||||
if child_status == 'REFUNDED' or child_data.get('voided'):
|
||||
raise ValidationError(_(
|
||||
"A linked transaction (%(child_id)s) has already "
|
||||
"been %(status)s. Voiding would cause a double "
|
||||
"reversal.",
|
||||
child_id=child_id,
|
||||
status='refunded' if child_status == 'REFUNDED' else 'voided',
|
||||
))
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
_logger.info(
|
||||
"Voiding Poynt transaction: original=%s, target=%s",
|
||||
txn_id, void_target_id,
|
||||
)
|
||||
|
||||
already_voided = txn_data.get('voided', False)
|
||||
if already_voided:
|
||||
_logger.info("Transaction %s is already voided on Poynt, skipping API call.", txn_id)
|
||||
result = txn_data
|
||||
else:
|
||||
result = provider._poynt_make_request(
|
||||
'POST', f'transactions/{void_target_id}/void',
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Poynt void response: status=%s, voided=%s, id=%s",
|
||||
result.get('status'), result.get('voided'), result.get('id'),
|
||||
)
|
||||
|
||||
is_voided = result.get('voided', False)
|
||||
void_status = result.get('status', '')
|
||||
|
||||
if not is_voided and void_status not in ('VOIDED', 'REFUNDED'):
|
||||
if void_status == 'DECLINED':
|
||||
raise ValidationError(_(
|
||||
"Void declined by the payment processor. This usually "
|
||||
"means the batch has already settled (past the daily "
|
||||
"closeout at 6:00 PM). Settled transactions cannot be "
|
||||
"voided.\n\n"
|
||||
"To reverse this payment, create a Credit Note on the "
|
||||
"invoice and process a refund through the standard "
|
||||
"Odoo workflow."
|
||||
))
|
||||
raise ValidationError(
|
||||
_("Poynt did not confirm the void. Status: %(status)s",
|
||||
status=void_status)
|
||||
)
|
||||
|
||||
if self.payment_id:
|
||||
self.payment_id.sudo().action_cancel()
|
||||
|
||||
self.sudo().write({
|
||||
'state': 'cancel',
|
||||
'poynt_voided': True,
|
||||
'poynt_void_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
invoice = self.invoice_ids[:1]
|
||||
if invoice:
|
||||
invoice.sudo().message_post(
|
||||
body=_(
|
||||
"Payment voided: transaction %(ref)s was voided on Poynt "
|
||||
"(Poynt void ID: %(void_id)s).",
|
||||
ref=self.reference,
|
||||
void_id=result.get('id', ''),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'type': 'success',
|
||||
'message': _("Transaction voided successfully on Poynt."),
|
||||
'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
|
||||
},
|
||||
}
|
||||
|
||||
# === BUSINESS METHODS - NOTIFICATION PROCESSING === #
|
||||
|
||||
@api.model
|
||||
@@ -311,6 +502,16 @@ class PaymentTransaction(models.Model):
|
||||
|
||||
return tx
|
||||
|
||||
def _extract_amount_data(self, payment_data):
|
||||
"""Override of `payment` to skip amount validation for Poynt.
|
||||
|
||||
Terminal payments may include tips or rounding adjustments, so we
|
||||
return None to opt out of the strict amount comparison.
|
||||
"""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._extract_amount_data(payment_data)
|
||||
return None
|
||||
|
||||
def _apply_updates(self, payment_data):
|
||||
"""Override of `payment` to update the transaction based on Poynt data."""
|
||||
if self.provider_code != 'poynt':
|
||||
@@ -347,13 +548,14 @@ class PaymentTransaction(models.Model):
|
||||
self._set_authorized()
|
||||
elif odoo_state == 'done':
|
||||
self._set_done()
|
||||
if self.operation == 'refund':
|
||||
self.env.ref('payment.cron_post_process_payment_tx')._trigger()
|
||||
self._post_process()
|
||||
self._poynt_generate_receipt(payment_data)
|
||||
elif odoo_state == 'cancel':
|
||||
self._set_canceled()
|
||||
elif odoo_state == 'refund':
|
||||
self._set_done()
|
||||
self.env.ref('payment.cron_post_process_payment_tx')._trigger()
|
||||
self._post_process()
|
||||
self._poynt_generate_receipt(payment_data)
|
||||
elif odoo_state == 'error':
|
||||
error_msg = payment_data.get('error_message', _("Payment was declined by Poynt."))
|
||||
self._set_error(error_msg)
|
||||
@@ -366,6 +568,67 @@ class PaymentTransaction(models.Model):
|
||||
_("Received data with unrecognized status: %s.", status)
|
||||
)
|
||||
|
||||
def _create_payment(self, **extra_create_values):
|
||||
"""Override to route Poynt payments directly to the bank account.
|
||||
|
||||
Card payments via Poynt are settled instantly -- there is no separate
|
||||
bank reconciliation step. We swap the ``outstanding_account_id`` to
|
||||
the journal's default (bank) account before posting so the payment
|
||||
transitions to ``paid`` instead of lingering in ``in_process``.
|
||||
"""
|
||||
if self.provider_code != 'poynt':
|
||||
return super()._create_payment(**extra_create_values)
|
||||
|
||||
self.ensure_one()
|
||||
reference = f'{self.reference} - {self.provider_reference or ""}'
|
||||
payment_method_line = self.provider_id.journal_id.inbound_payment_method_line_ids\
|
||||
.filtered(lambda l: l.payment_provider_id == self.provider_id)
|
||||
payment_values = {
|
||||
'amount': abs(self.amount),
|
||||
'payment_type': 'inbound' if self.amount > 0 else 'outbound',
|
||||
'currency_id': self.currency_id.id,
|
||||
'partner_id': self.partner_id.commercial_partner_id.id,
|
||||
'partner_type': 'customer',
|
||||
'journal_id': self.provider_id.journal_id.id,
|
||||
'company_id': self.provider_id.company_id.id,
|
||||
'payment_method_line_id': payment_method_line.id,
|
||||
'payment_token_id': self.token_id.id,
|
||||
'payment_transaction_id': self.id,
|
||||
'memo': reference,
|
||||
'write_off_line_vals': [],
|
||||
'invoice_ids': self.invoice_ids,
|
||||
**extra_create_values,
|
||||
}
|
||||
|
||||
payment_term_lines = self.invoice_ids.line_ids.filtered(
|
||||
lambda line: line.display_type == 'payment_term'
|
||||
)
|
||||
if payment_term_lines:
|
||||
payment_values['destination_account_id'] = payment_term_lines[0].account_id.id
|
||||
|
||||
payment = self.env['account.payment'].create(payment_values)
|
||||
|
||||
bank_account = self.provider_id.journal_id.default_account_id
|
||||
if bank_account and bank_account.account_type == 'asset_cash':
|
||||
payment.outstanding_account_id = bank_account
|
||||
|
||||
payment.action_post()
|
||||
self.payment_id = payment
|
||||
|
||||
if self.operation == self.source_transaction_id.operation:
|
||||
invoices = self.source_transaction_id.invoice_ids
|
||||
else:
|
||||
invoices = self.invoice_ids
|
||||
invoices = invoices.filtered(lambda inv: inv.state != 'cancel')
|
||||
if invoices:
|
||||
invoices.filtered(lambda inv: inv.state == 'draft').action_post()
|
||||
(payment.move_id.line_ids + invoices.line_ids).filtered(
|
||||
lambda line: line.account_id == payment.destination_account_id
|
||||
and not line.reconciled
|
||||
).reconcile()
|
||||
|
||||
return payment
|
||||
|
||||
def _extract_token_values(self, payment_data):
|
||||
"""Override of `payment` to return token data based on Poynt data."""
|
||||
if self.provider_code != 'poynt':
|
||||
@@ -384,3 +647,163 @@ class PaymentTransaction(models.Model):
|
||||
'payment_details': card_details.get('last4', ''),
|
||||
'poynt_card_id': card_details.get('card_id', ''),
|
||||
}
|
||||
|
||||
# === RECEIPT GENERATION === #
|
||||
|
||||
def _poynt_generate_receipt(self, payment_data=None):
|
||||
"""Fetch transaction details from Poynt, store receipt data, generate
|
||||
a PDF receipt, and attach it to the linked invoice's chatter.
|
||||
|
||||
This method is best-effort: failures are logged but never block
|
||||
the payment flow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.provider_code != 'poynt' or not self.poynt_transaction_id:
|
||||
return
|
||||
|
||||
try:
|
||||
self._poynt_store_receipt_data(payment_data)
|
||||
self._poynt_attach_receipt_pdf()
|
||||
self._poynt_attach_poynt_receipt()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Receipt generation failed for transaction %s", self.reference,
|
||||
)
|
||||
|
||||
def _poynt_store_receipt_data(self, payment_data=None):
|
||||
"""Fetch the full Poynt transaction and persist receipt-relevant
|
||||
fields in :attr:`poynt_receipt_data` as a JSON blob."""
|
||||
txn_data = {}
|
||||
try:
|
||||
txn_data = self.provider_id._poynt_make_request(
|
||||
'GET', f'transactions/{self.poynt_transaction_id}',
|
||||
)
|
||||
except (ValidationError, Exception):
|
||||
_logger.debug(
|
||||
"Could not fetch Poynt txn %s for receipt", self.poynt_transaction_id,
|
||||
)
|
||||
|
||||
funding = payment_data.get('funding_source', {}) if payment_data else {}
|
||||
if not funding:
|
||||
funding = txn_data.get('fundingSource', {})
|
||||
|
||||
card = funding.get('card', {})
|
||||
entry = funding.get('entryDetails', {})
|
||||
amounts = txn_data.get('amounts', {})
|
||||
processor = txn_data.get('processorResponse', {})
|
||||
context = txn_data.get('context', {})
|
||||
|
||||
currency_name = amounts.get('currency', self.currency_id.name)
|
||||
decimals = const.CURRENCY_DECIMALS.get(currency_name, 2)
|
||||
|
||||
receipt = {
|
||||
'transaction_id': self.poynt_transaction_id,
|
||||
'order_id': self.poynt_order_id or '',
|
||||
'reference': self.reference,
|
||||
'status': txn_data.get('status', payment_data.get('poynt_status', '') if payment_data else ''),
|
||||
'created_at': txn_data.get('createdAt', ''),
|
||||
'card_type': card.get('type', ''),
|
||||
'card_last4': card.get('numberLast4', ''),
|
||||
'card_first6': card.get('numberFirst6', ''),
|
||||
'card_holder': card.get('cardHolderFullName', ''),
|
||||
'entry_mode': entry.get('entryMode', ''),
|
||||
'customer_presence': entry.get('customerPresenceStatus', ''),
|
||||
'transaction_amount': amounts.get('transactionAmount', 0) / (10 ** decimals) if amounts.get('transactionAmount') else float(self.amount),
|
||||
'tip_amount': amounts.get('tipAmount', 0) / (10 ** decimals) if amounts.get('tipAmount') else 0,
|
||||
'currency': currency_name,
|
||||
'approval_code': processor.get('approvalCode', txn_data.get('approvalCode', '')),
|
||||
'processor': processor.get('processor', ''),
|
||||
'processor_status': processor.get('status', ''),
|
||||
'store_id': context.get('storeId', ''),
|
||||
'device_id': context.get('storeDeviceId', ''),
|
||||
}
|
||||
|
||||
self.poynt_receipt_data = json.dumps(receipt)
|
||||
|
||||
def _poynt_attach_receipt_pdf(self):
|
||||
"""Render the QWeb receipt report and attach the PDF to the invoice."""
|
||||
invoice = self.invoice_ids[:1]
|
||||
if not invoice:
|
||||
return
|
||||
|
||||
try:
|
||||
report = self.env.ref('fusion_poynt.action_report_poynt_receipt')
|
||||
pdf_content, _report_type = report._render_qweb_pdf(report.report_name, [self.id])
|
||||
except Exception:
|
||||
_logger.debug("Could not render Poynt receipt PDF for %s", self.reference)
|
||||
return
|
||||
|
||||
filename = f"Payment_Receipt_{self.reference}.pdf"
|
||||
attachment = self.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(pdf_content),
|
||||
'res_model': 'account.move',
|
||||
'res_id': invoice.id,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
|
||||
invoice.sudo().message_post(
|
||||
body=_(
|
||||
"Payment receipt generated for transaction %(ref)s.",
|
||||
ref=self.reference,
|
||||
),
|
||||
attachment_ids=[attachment.id],
|
||||
)
|
||||
|
||||
def _poynt_attach_poynt_receipt(self):
|
||||
"""Try the Poynt renderReceipt endpoint and attach the result."""
|
||||
invoice = self.invoice_ids[:1]
|
||||
if not invoice:
|
||||
return
|
||||
|
||||
receipt_content = self.provider_id._poynt_fetch_receipt(
|
||||
self.poynt_transaction_id,
|
||||
)
|
||||
if not receipt_content:
|
||||
return
|
||||
|
||||
filename = f"Poynt_Receipt_{self.reference}.html"
|
||||
self.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(receipt_content.encode('utf-8')),
|
||||
'res_model': 'account.move',
|
||||
'res_id': invoice.id,
|
||||
'mimetype': 'text/html',
|
||||
})
|
||||
|
||||
def _get_poynt_receipt_values(self):
|
||||
"""Parse the stored receipt JSON for use in QWeb templates.
|
||||
|
||||
For refund transactions that lack their own receipt data, this
|
||||
falls back to the source (sale) transaction's receipt data so the
|
||||
card details and approval codes are still available.
|
||||
|
||||
:return: Dict of receipt values or empty dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
data = self.poynt_receipt_data
|
||||
if not data and self.source_transaction_id:
|
||||
data = self.source_transaction_id.poynt_receipt_data
|
||||
if not data:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
def _get_source_receipt_values(self):
|
||||
"""Return receipt values from the original sale transaction.
|
||||
|
||||
Used by the refund receipt template to render the original sale
|
||||
details on a second page.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.source_transaction_id and self.source_transaction_id.poynt_receipt_data:
|
||||
try:
|
||||
return json.loads(self.source_transaction_id.poynt_receipt_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
@@ -129,22 +130,29 @@ class PoyntTerminal(models.Model):
|
||||
if order_id:
|
||||
payment_request['orderId'] = order_id
|
||||
|
||||
store_id = self.store_id_poynt or self.provider_id.poynt_store_id or ''
|
||||
|
||||
data_str = json.dumps({
|
||||
'action': 'sale',
|
||||
'purchaseAmount': minor_amount,
|
||||
'tipAmount': 0,
|
||||
'currency': currency.name,
|
||||
'referenceId': reference,
|
||||
'callbackUrl': self._get_terminal_callback_url(),
|
||||
})
|
||||
|
||||
try:
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST',
|
||||
f'cloudMessages',
|
||||
'cloudMessages',
|
||||
business_scoped=False,
|
||||
payload={
|
||||
'businessId': self.provider_id.poynt_business_id,
|
||||
'storeId': store_id,
|
||||
'deviceId': self.device_id,
|
||||
'ttl': 300,
|
||||
'serialNum': self.serial_number or '',
|
||||
'data': {
|
||||
'action': 'sale',
|
||||
'purchaseAmount': minor_amount,
|
||||
'tipAmount': 0,
|
||||
'currency': currency.name,
|
||||
'referenceId': reference,
|
||||
'callbackUrl': self._get_terminal_callback_url(),
|
||||
},
|
||||
'data': data_str,
|
||||
},
|
||||
)
|
||||
_logger.info(
|
||||
@@ -171,6 +179,9 @@ class PoyntTerminal(models.Model):
|
||||
def action_check_terminal_payment_status(self, reference):
|
||||
"""Poll for the status of a terminal payment.
|
||||
|
||||
Searches Poynt transactions by referenceId (set via cloud message)
|
||||
and falls back to notes field.
|
||||
|
||||
:param str reference: The Odoo transaction reference.
|
||||
:return: Dict with status and transaction data if completed.
|
||||
:rtype: dict
|
||||
@@ -182,15 +193,37 @@ class PoyntTerminal(models.Model):
|
||||
'GET',
|
||||
'transactions',
|
||||
params={
|
||||
'notes': reference,
|
||||
'limit': 1,
|
||||
'referenceId': reference,
|
||||
'limit': 5,
|
||||
},
|
||||
)
|
||||
|
||||
transactions = txn_result.get('transactions', [])
|
||||
|
||||
if not transactions:
|
||||
txn_result = self.provider_id._poynt_make_request(
|
||||
'GET',
|
||||
'transactions',
|
||||
params={
|
||||
'notes': reference,
|
||||
'limit': 5,
|
||||
},
|
||||
)
|
||||
transactions = txn_result.get('transactions', [])
|
||||
|
||||
if not transactions:
|
||||
return {'status': 'pending', 'message': 'Waiting for terminal response...'}
|
||||
|
||||
for txn in transactions:
|
||||
status = txn.get('status', 'UNKNOWN')
|
||||
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED'):
|
||||
return {
|
||||
'status': status,
|
||||
'transaction_id': txn.get('id', ''),
|
||||
'funding_source': txn.get('fundingSource', {}),
|
||||
'amounts': txn.get('amounts', {}),
|
||||
}
|
||||
|
||||
txn = transactions[0]
|
||||
return {
|
||||
'status': txn.get('status', 'UNKNOWN'),
|
||||
|
||||
60
fusion_poynt/models/sale_order.py
Normal file
60
fusion_poynt/models/sale_order.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
def action_poynt_collect_payment(self):
|
||||
"""Create an invoice (if needed) and open the Poynt payment wizard.
|
||||
|
||||
This shortcut lets staff collect payment directly from a confirmed
|
||||
sale order without manually creating and posting the invoice first.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if self.state not in ('sale', 'done'):
|
||||
raise UserError(
|
||||
_("You can only collect payment on confirmed orders.")
|
||||
)
|
||||
|
||||
invoice = self.invoice_ids.filtered(
|
||||
lambda inv: inv.state == 'posted'
|
||||
and inv.payment_state in ('not_paid', 'partial')
|
||||
and inv.move_type == 'out_invoice'
|
||||
)[:1]
|
||||
|
||||
if not invoice:
|
||||
draft_invoices = self.invoice_ids.filtered(
|
||||
lambda inv: inv.state == 'draft'
|
||||
and inv.move_type == 'out_invoice'
|
||||
)
|
||||
if draft_invoices:
|
||||
invoice = draft_invoices[0]
|
||||
invoice.action_post()
|
||||
else:
|
||||
invoices = self._create_invoices()
|
||||
if not invoices:
|
||||
raise UserError(
|
||||
_("Could not create an invoice for this order.")
|
||||
)
|
||||
invoice = invoices[0]
|
||||
invoice.action_post()
|
||||
|
||||
return {
|
||||
'name': _("Collect Poynt Payment"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'poynt.payment.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'active_model': 'account.move',
|
||||
'active_id': invoice.id,
|
||||
},
|
||||
}
|
||||
14
fusion_poynt/report/poynt_receipt_report.xml
Normal file
14
fusion_poynt/report/poynt_receipt_report.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_report_poynt_receipt" model="ir.actions.report">
|
||||
<field name="name">Poynt Payment Receipt</field>
|
||||
<field name="model">payment.transaction</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_poynt.report_poynt_receipt_document</field>
|
||||
<field name="report_file">fusion_poynt.report_poynt_receipt_document</field>
|
||||
<field name="print_report_name">'Payment_Receipt_%s' % object.reference</field>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
303
fusion_poynt/report/poynt_receipt_templates.xml
Normal file
303
fusion_poynt/report/poynt_receipt_templates.xml
Normal file
@@ -0,0 +1,303 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="report_poynt_receipt_document">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="tx">
|
||||
<t t-set="receipt" t-value="tx._get_poynt_receipt_values()"/>
|
||||
<t t-set="company" t-value="tx.company_id or tx.env.company"/>
|
||||
<t t-set="is_refund" t-value="tx.operation == 'refund' or tx.amount < 0"/>
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page" style="font-family: 'Courier New', Courier, monospace;">
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<h4>
|
||||
<strong t-if="is_refund">REFUND RECEIPT</strong>
|
||||
<strong t-else="">PAYMENT RECEIPT</strong>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Transaction details table -->
|
||||
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 40%;">Date</td>
|
||||
<td>
|
||||
<t t-if="receipt.get('created_at')">
|
||||
<t t-esc="receipt['created_at']"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-field="tx.create_date" t-options="{'widget': 'datetime'}"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Reference</td>
|
||||
<td><strong t-field="tx.reference"/></td>
|
||||
</tr>
|
||||
<tr t-if="receipt.get('transaction_id')">
|
||||
<td class="text-muted">Transaction ID</td>
|
||||
<td style="font-size: 11px;"><t t-esc="receipt['transaction_id']"/></td>
|
||||
</tr>
|
||||
<tr t-if="is_refund and tx.source_transaction_id">
|
||||
<td class="text-muted">Original Transaction</td>
|
||||
<td style="font-size: 11px;"><t t-esc="tx.source_transaction_id.reference"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Type</td>
|
||||
<td>
|
||||
<strong t-if="is_refund" style="color: #dc3545;">REFUND</strong>
|
||||
<strong t-else="" style="color: #28a745;">SALE</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr style="border-top: 1px solid #999;"/>
|
||||
|
||||
<!-- Card info -->
|
||||
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||
<tbody>
|
||||
<tr t-if="receipt.get('card_type')">
|
||||
<td class="text-muted" style="width: 40%;">Card Type</td>
|
||||
<td><t t-esc="receipt['card_type']"/></td>
|
||||
</tr>
|
||||
<tr t-if="receipt.get('card_last4')">
|
||||
<td class="text-muted">Card Number</td>
|
||||
<td>**** **** **** <t t-esc="receipt['card_last4']"/></td>
|
||||
</tr>
|
||||
<tr t-if="receipt.get('card_holder')">
|
||||
<td class="text-muted">Cardholder</td>
|
||||
<td><t t-esc="receipt['card_holder']"/></td>
|
||||
</tr>
|
||||
<tr t-if="receipt.get('entry_mode')">
|
||||
<td class="text-muted">Entry Mode</td>
|
||||
<td><t t-esc="receipt['entry_mode']"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr style="border-top: 1px solid #999;"/>
|
||||
|
||||
<!-- Amounts -->
|
||||
<table class="table table-sm table-borderless" style="font-size: 14px;">
|
||||
<tbody>
|
||||
<tr t-if="receipt.get('tip_amount') and not is_refund">
|
||||
<td class="text-muted" style="width: 40%;">Subtotal</td>
|
||||
<td class="text-end">
|
||||
<t t-esc="receipt.get('currency', 'CAD')"/>
|
||||
<t t-esc="'%.2f' % (receipt.get('transaction_amount', 0) - receipt.get('tip_amount', 0))"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="receipt.get('tip_amount') and not is_refund">
|
||||
<td class="text-muted">Tip</td>
|
||||
<td class="text-end">
|
||||
<t t-esc="receipt.get('currency', 'CAD')"/>
|
||||
<t t-esc="'%.2f' % receipt['tip_amount']"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong t-if="is_refund">REFUND TOTAL</strong>
|
||||
<strong t-else="">TOTAL</strong>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<strong t-attf-style="color: {{ 'dc3545' if is_refund else '000' }};">
|
||||
<t t-if="is_refund">- </t>
|
||||
<t t-esc="receipt.get('currency', 'CAD')"/>
|
||||
<t t-esc="'%.2f' % abs(receipt.get('transaction_amount', 0) or abs(tx.amount))"/>
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr style="border-top: 1px solid #999;"/>
|
||||
|
||||
<!-- Approval -->
|
||||
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||
<tbody>
|
||||
<tr t-if="receipt.get('approval_code')">
|
||||
<td class="text-muted" style="width: 40%;">Approval Code</td>
|
||||
<td><strong><t t-esc="receipt['approval_code']"/></strong></td>
|
||||
</tr>
|
||||
<tr t-if="receipt.get('status')">
|
||||
<td class="text-muted">Status</td>
|
||||
<td><t t-esc="receipt['status']"/></td>
|
||||
</tr>
|
||||
<tr t-if="receipt.get('processor')">
|
||||
<td class="text-muted">Processor</td>
|
||||
<td><t t-esc="receipt['processor']"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr style="border-top: 2px dashed #333;"/>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-3" style="font-size: 12px;">
|
||||
<p class="mb-1">
|
||||
<t t-if="is_refund">Credit Note: </t>
|
||||
<t t-else="">Invoice: </t>
|
||||
<strong t-esc="', '.join(tx.invoice_ids.mapped('name'))" />
|
||||
</p>
|
||||
<p class="mb-1">
|
||||
Customer: <strong t-field="tx.partner_id.name"/>
|
||||
</p>
|
||||
<p class="text-muted mt-3" t-if="is_refund">
|
||||
Refund processed. The amount will be credited to your
|
||||
card within 3-5 business days.
|
||||
</p>
|
||||
<p class="text-muted mt-3" t-else="">
|
||||
Thank you for your payment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Page 2: Original Sale Receipt (only for refunds) -->
|
||||
<t t-if="is_refund and tx.source_transaction_id">
|
||||
<t t-set="src_tx" t-value="tx.source_transaction_id"/>
|
||||
<t t-set="src_receipt" t-value="tx._get_source_receipt_values()"/>
|
||||
<t t-if="src_receipt">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page" style="font-family: 'Courier New', Courier, monospace;">
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<h4><strong>ORIGINAL SALE RECEIPT</strong></h4>
|
||||
<p class="text-muted" style="font-size: 12px;">
|
||||
Reference for refund <strong t-field="tx.reference"/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Transaction details -->
|
||||
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 40%;">Date</td>
|
||||
<td>
|
||||
<t t-if="src_receipt.get('created_at')">
|
||||
<t t-esc="src_receipt['created_at']"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-field="src_tx.create_date" t-options="{'widget': 'datetime'}"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Reference</td>
|
||||
<td><strong t-field="src_tx.reference"/></td>
|
||||
</tr>
|
||||
<tr t-if="src_receipt.get('transaction_id')">
|
||||
<td class="text-muted">Transaction ID</td>
|
||||
<td style="font-size: 11px;"><t t-esc="src_receipt['transaction_id']"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Type</td>
|
||||
<td><strong style="color: #28a745;">SALE</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr style="border-top: 1px solid #999;"/>
|
||||
|
||||
<!-- Card info -->
|
||||
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||
<tbody>
|
||||
<tr t-if="src_receipt.get('card_type')">
|
||||
<td class="text-muted" style="width: 40%;">Card Type</td>
|
||||
<td><t t-esc="src_receipt['card_type']"/></td>
|
||||
</tr>
|
||||
<tr t-if="src_receipt.get('card_last4')">
|
||||
<td class="text-muted">Card Number</td>
|
||||
<td>**** **** **** <t t-esc="src_receipt['card_last4']"/></td>
|
||||
</tr>
|
||||
<tr t-if="src_receipt.get('card_holder')">
|
||||
<td class="text-muted">Cardholder</td>
|
||||
<td><t t-esc="src_receipt['card_holder']"/></td>
|
||||
</tr>
|
||||
<tr t-if="src_receipt.get('entry_mode')">
|
||||
<td class="text-muted">Entry Mode</td>
|
||||
<td><t t-esc="src_receipt['entry_mode']"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr style="border-top: 1px solid #999;"/>
|
||||
|
||||
<!-- Amounts -->
|
||||
<table class="table table-sm table-borderless" style="font-size: 14px;">
|
||||
<tbody>
|
||||
<tr t-if="src_receipt.get('tip_amount')">
|
||||
<td class="text-muted" style="width: 40%;">Subtotal</td>
|
||||
<td class="text-end">
|
||||
<t t-esc="src_receipt.get('currency', 'CAD')"/>
|
||||
<t t-esc="'%.2f' % (src_receipt.get('transaction_amount', 0) - src_receipt.get('tip_amount', 0))"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="src_receipt.get('tip_amount')">
|
||||
<td class="text-muted">Tip</td>
|
||||
<td class="text-end">
|
||||
<t t-esc="src_receipt.get('currency', 'CAD')"/>
|
||||
<t t-esc="'%.2f' % src_receipt['tip_amount']"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>TOTAL</strong></td>
|
||||
<td class="text-end">
|
||||
<strong>
|
||||
<t t-esc="src_receipt.get('currency', 'CAD')"/>
|
||||
<t t-esc="'%.2f' % abs(src_receipt.get('transaction_amount', 0) or abs(src_tx.amount))"/>
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr style="border-top: 1px solid #999;"/>
|
||||
|
||||
<!-- Approval -->
|
||||
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||
<tbody>
|
||||
<tr t-if="src_receipt.get('approval_code')">
|
||||
<td class="text-muted" style="width: 40%;">Approval Code</td>
|
||||
<td><strong><t t-esc="src_receipt['approval_code']"/></strong></td>
|
||||
</tr>
|
||||
<tr t-if="src_receipt.get('status')">
|
||||
<td class="text-muted">Status</td>
|
||||
<td><t t-esc="src_receipt['status']"/></td>
|
||||
</tr>
|
||||
<tr t-if="src_receipt.get('processor')">
|
||||
<td class="text-muted">Processor</td>
|
||||
<td><t t-esc="src_receipt['processor']"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr style="border-top: 2px dashed #333;"/>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-3" style="font-size: 12px;">
|
||||
<p class="mb-1">
|
||||
Invoice: <strong t-esc="', '.join(src_tx.invoice_ids.mapped('name'))"/>
|
||||
</p>
|
||||
<p class="mb-1">
|
||||
Customer: <strong t-field="src_tx.partner_id.name"/>
|
||||
</p>
|
||||
<p class="text-muted mt-3">
|
||||
This is the original sale transaction associated
|
||||
with the refund on Page 1.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -1,3 +1,7 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_poynt_terminal_user,poynt.terminal.user,model_poynt_terminal,base.group_user,1,0,0,0
|
||||
access_poynt_terminal_admin,poynt.terminal.admin,model_poynt_terminal,base.group_system,1,1,1,1
|
||||
access_poynt_payment_wizard_user,poynt.payment.wizard.user,model_poynt_payment_wizard,account.group_account_invoice,1,1,1,0
|
||||
access_poynt_payment_wizard_admin,poynt.payment.wizard.admin,model_poynt_payment_wizard,base.group_system,1,1,1,1
|
||||
access_poynt_refund_wizard_user,poynt.refund.wizard.user,model_poynt_refund_wizard,account.group_account_invoice,1,1,1,0
|
||||
access_poynt_refund_wizard_admin,poynt.refund.wizard.admin,model_poynt_refund_wizard,base.group_system,1,1,1,1
|
||||
|
||||
|
BIN
fusion_poynt/static/src/img/poynt_logo.png
Normal file
BIN
fusion_poynt/static/src/img/poynt_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
69
fusion_poynt/static/src/js/poynt_wizard_poll.js
Normal file
69
fusion_poynt/static/src/js/poynt_wizard_poll.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component, onMounted, onWillUnmount, useState } from "@odoo/owl";
|
||||
|
||||
class PoyntPollAction extends Component {
|
||||
static template = "fusion_poynt.PoyntPollAction";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.state = useState({ seconds: 0, message: "" });
|
||||
|
||||
const wizardId = this.props.action?.params?.wizard_id;
|
||||
this.wizardId = wizardId;
|
||||
|
||||
onMounted(() => {
|
||||
if (!this.wizardId) return;
|
||||
this._tickTimer = setInterval(() => this.state.seconds++, 1000);
|
||||
this._pollTimer = setInterval(() => this._poll(), 5000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => this._stop());
|
||||
}
|
||||
|
||||
_stop() {
|
||||
if (this._tickTimer) { clearInterval(this._tickTimer); this._tickTimer = null; }
|
||||
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
||||
}
|
||||
|
||||
async _poll() {
|
||||
if (!this.wizardId) { this._stop(); return; }
|
||||
try {
|
||||
const result = await this.orm.call(
|
||||
"poynt.payment.wizard", "action_check_status", [[this.wizardId]],
|
||||
);
|
||||
if (result && result.type) {
|
||||
this._stop();
|
||||
this.actionService.doAction(result, { clearBreadcrumbs: true });
|
||||
}
|
||||
} catch (e) {
|
||||
this._stop();
|
||||
this.state.message = "Polling stopped due to an error. Use Check Now to retry.";
|
||||
}
|
||||
}
|
||||
|
||||
async onManualCheck() {
|
||||
this.state.message = "";
|
||||
if (!this._pollTimer) {
|
||||
this._pollTimer = setInterval(() => this._poll(), 5000);
|
||||
this._tickTimer = setInterval(() => this.state.seconds++, 1000);
|
||||
}
|
||||
await this._poll();
|
||||
}
|
||||
|
||||
async onCancel() {
|
||||
this._stop();
|
||||
try {
|
||||
await this.orm.call(
|
||||
"poynt.payment.wizard", "action_cancel_payment", [[this.wizardId]],
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
this.actionService.doAction({ type: "ir.actions.act_window_close" });
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("poynt_poll_action", PoyntPollAction);
|
||||
27
fusion_poynt/static/src/xml/poynt_wizard_poll.xml
Normal file
27
fusion_poynt/static/src/xml/poynt_wizard_poll.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_poynt.PoyntPollAction">
|
||||
<div class="o_action p-4">
|
||||
<div class="alert alert-info d-flex align-items-center gap-3 mb-4">
|
||||
<span class="spinner-border spinner-border-sm" role="status"/>
|
||||
<div>
|
||||
<strong>Waiting for terminal response...</strong>
|
||||
<div class="text-muted small mt-1">
|
||||
Auto-checking every 5 seconds
|
||||
(<t t-esc="state.seconds"/>s elapsed)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary" t-on-click="onManualCheck">
|
||||
Check Now
|
||||
</button>
|
||||
<button class="btn btn-secondary" t-on-click="onCancel">
|
||||
Cancel Payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -41,16 +41,37 @@ def build_api_headers(access_token, request_id=None):
|
||||
:return: The request headers dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
if not request_id:
|
||||
request_id = generate_request_id()
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Api-Version': const.API_VERSION,
|
||||
'api-version': const.API_VERSION,
|
||||
'Accept': 'application/json',
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Poynt-Request-Id': request_id,
|
||||
}
|
||||
if request_id:
|
||||
headers['POYNT-REQUEST-ID'] = request_id
|
||||
return headers
|
||||
|
||||
|
||||
def clean_application_id(raw_app_id):
|
||||
"""Extract the urn:aid:... portion from a raw application ID string.
|
||||
|
||||
Poynt developer portal sometimes displays the app UUID and URN together
|
||||
(e.g. 'a73a2957-...=urn:aid:fb0ba879-...'). The JWT needs only the URN.
|
||||
|
||||
:param str raw_app_id: The raw application ID string.
|
||||
:return: The cleaned application ID (urn:aid:...).
|
||||
:rtype: str
|
||||
"""
|
||||
if not raw_app_id:
|
||||
return raw_app_id
|
||||
raw_app_id = raw_app_id.strip()
|
||||
if 'urn:aid:' in raw_app_id:
|
||||
idx = raw_app_id.index('urn:aid:')
|
||||
return raw_app_id[idx:]
|
||||
return raw_app_id
|
||||
|
||||
|
||||
def create_self_signed_jwt(application_id, private_key_pem):
|
||||
"""Create a self-signed JWT for Poynt OAuth2 token request.
|
||||
|
||||
@@ -81,10 +102,12 @@ def create_self_signed_jwt(application_id, private_key_pem):
|
||||
|
||||
private_key = load_pem_private_key(key_bytes, password=None, backend=default_backend())
|
||||
|
||||
app_id = clean_application_id(application_id)
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
'iss': application_id,
|
||||
'sub': application_id,
|
||||
'iss': app_id,
|
||||
'sub': app_id,
|
||||
'aud': 'https://services.poynt.net',
|
||||
'iat': now,
|
||||
'exp': now + 300,
|
||||
@@ -162,12 +185,15 @@ def get_poynt_status(status_str):
|
||||
return 'error'
|
||||
|
||||
|
||||
def build_order_payload(reference, amount, currency, items=None, notes=''):
|
||||
def build_order_payload(reference, amount, currency, business_id='',
|
||||
store_id='', items=None, notes=''):
|
||||
"""Build a Poynt order creation payload.
|
||||
|
||||
:param str reference: The Odoo transaction reference.
|
||||
:param float amount: The order total in major currency units.
|
||||
:param recordset currency: The currency record.
|
||||
:param str business_id: The Poynt business UUID.
|
||||
:param str store_id: The Poynt store UUID.
|
||||
:param list items: Optional list of order item dicts.
|
||||
:param str notes: Optional order notes.
|
||||
:return: The Poynt-formatted order payload.
|
||||
@@ -185,6 +211,15 @@ def build_order_payload(reference, amount, currency, items=None, notes=''):
|
||||
'unitOfMeasure': 'EACH',
|
||||
}]
|
||||
|
||||
context = {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
}
|
||||
if business_id:
|
||||
context['businessId'] = business_id
|
||||
if store_id:
|
||||
context['storeId'] = store_id
|
||||
|
||||
return {
|
||||
'items': items,
|
||||
'amounts': {
|
||||
@@ -195,10 +230,7 @@ def build_order_payload(reference, amount, currency, items=None, notes=''):
|
||||
'netTotal': minor_amount,
|
||||
'currency': currency.name,
|
||||
},
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
|
||||
},
|
||||
'context': context,
|
||||
'statuses': {
|
||||
'status': 'OPENED',
|
||||
},
|
||||
|
||||
83
fusion_poynt/views/account_move_views.xml
Normal file
83
fusion_poynt/views/account_move_views.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_move_form_poynt_button" model="ir.ui.view">
|
||||
<field name="name">account.move.form.poynt.button</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="priority">60</field>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Poynt Refund smart button on invoices -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_poynt_refunds"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-undo"
|
||||
invisible="poynt_refund_count == 0">
|
||||
<field name="poynt_refund_count" widget="statinfo" string="Poynt Refunds"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Collect payment button on invoices -->
|
||||
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
|
||||
<button name="action_open_poynt_payment_wizard"
|
||||
string="Collect Poynt Payment"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-credit-card"
|
||||
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_invoice'"
|
||||
groups="account.group_account_invoice"
|
||||
data-hotkey="p"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Refund via Poynt button on credit notes -->
|
||||
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
|
||||
<button name="action_open_poynt_refund_wizard"
|
||||
string="Refund via Poynt"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-undo"
|
||||
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_refund' or poynt_refunded"
|
||||
groups="account.group_account_invoice"
|
||||
data-hotkey="r"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Resend Receipt button on invoices (paid via Poynt) -->
|
||||
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
|
||||
<button name="action_resend_poynt_receipt"
|
||||
string="Resend Receipt"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-envelope"
|
||||
invisible="state != 'posted' or move_type != 'out_invoice' or not has_poynt_receipt"
|
||||
groups="account.group_account_invoice"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Resend Receipt button on credit notes (refunded via Poynt) -->
|
||||
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
|
||||
<button name="action_resend_poynt_receipt"
|
||||
string="Resend Refund Receipt"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-envelope"
|
||||
invisible="state != 'posted' or move_type != 'out_refund' or not poynt_refunded"
|
||||
groups="account.group_account_invoice"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Refunded banner on credit notes -->
|
||||
<xpath expr="//header" position="before">
|
||||
<div class="alert alert-info text-center mb-0"
|
||||
role="status"
|
||||
invisible="not poynt_refunded">
|
||||
<strong>Refunded via Poynt</strong> — This credit note has been
|
||||
refunded to the customer's card through Poynt.
|
||||
</div>
|
||||
<field name="poynt_refunded" invisible="1"/>
|
||||
<field name="has_poynt_receipt" invisible="1"/>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -24,6 +24,7 @@
|
||||
<div class="o_row" col="2">
|
||||
<field name="poynt_webhook_secret" password="True"/>
|
||||
</div>
|
||||
<field name="poynt_default_terminal_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group name="provider_credentials" position="after">
|
||||
|
||||
40
fusion_poynt/views/payment_transaction_views.xml
Normal file
40
fusion_poynt/views/payment_transaction_views.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="payment_transaction_form_inherit_poynt" model="ir.ui.view">
|
||||
<field name="name">payment.transaction.form.inherit.poynt</field>
|
||||
<field name="model">payment.transaction</field>
|
||||
<field name="inherit_id" ref="payment.payment_transaction_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Voided banner at top of form -->
|
||||
<xpath expr="//header" position="before">
|
||||
<div class="alert alert-warning text-center mb-0"
|
||||
role="alert"
|
||||
invisible="not poynt_voided">
|
||||
<strong>VOIDED</strong> — This transaction was voided on Poynt
|
||||
on <field name="poynt_void_date" widget="datetime" readonly="1" class="d-inline"/>.
|
||||
The payment has been reversed before settlement.
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- Void button in header -->
|
||||
<xpath expr="//header/button[@name='action_void']" position="after">
|
||||
<button string="Void Transaction"
|
||||
name="action_poynt_void"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
invisible="state != 'done' or provider_code != 'poynt'"
|
||||
confirm="Are you sure you want to void this transaction? This reverses the payment before settlement and cannot be undone. Only works same-day before closeout (6 PM)."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Add voided fields to the form sheet -->
|
||||
<xpath expr="//field[@name='provider_reference']" position="after">
|
||||
<field name="poynt_voided" invisible="1"/>
|
||||
<field name="poynt_void_date" invisible="not poynt_voided"/>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -100,11 +100,11 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu entry under Payment Providers -->
|
||||
<!-- Menu entry as sibling of Payment Providers under Online Payments -->
|
||||
<menuitem id="menu_poynt_terminal"
|
||||
name="Poynt Terminals"
|
||||
parent="account_payment.payment_provider_menu"
|
||||
parent="account.root_payment_menu"
|
||||
action="action_poynt_terminal"
|
||||
sequence="30"/>
|
||||
sequence="15"/>
|
||||
|
||||
</odoo>
|
||||
|
||||
22
fusion_poynt/views/sale_order_views.xml
Normal file
22
fusion_poynt/views/sale_order_views.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_order_form_poynt_button" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.poynt.button</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="priority">60</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@id='create_invoice']" position="after">
|
||||
<button name="action_poynt_collect_payment"
|
||||
string="Collect Payment"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-credit-card"
|
||||
invisible="state not in ('sale', 'done')"
|
||||
data-hotkey="p"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
4
fusion_poynt/wizard/__init__.py
Normal file
4
fusion_poynt/wizard/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import poynt_payment_wizard
|
||||
from . import poynt_refund_wizard
|
||||
552
fusion_poynt/wizard/poynt_payment_wizard.py
Normal file
552
fusion_poynt/wizard/poynt_payment_wizard.py
Normal file
@@ -0,0 +1,552 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.fusion_poynt import utils as poynt_utils
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PoyntPaymentWizard(models.TransientModel):
|
||||
_name = 'poynt.payment.wizard'
|
||||
_description = 'Collect Poynt Payment'
|
||||
|
||||
invoice_id = fields.Many2one(
|
||||
'account.move',
|
||||
string="Invoice",
|
||||
required=True,
|
||||
readonly=True,
|
||||
domain="[('move_type', 'in', ('out_invoice', 'out_refund'))]",
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='invoice_id.partner_id',
|
||||
string="Customer",
|
||||
)
|
||||
amount = fields.Monetary(
|
||||
string="Amount",
|
||||
required=True,
|
||||
currency_field='currency_id',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string="Currency",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
provider_id = fields.Many2one(
|
||||
'payment.provider',
|
||||
string="Poynt Provider",
|
||||
required=True,
|
||||
domain="[('code', '=', 'poynt'), ('state', '!=', 'disabled')]",
|
||||
)
|
||||
|
||||
payment_mode = fields.Selection(
|
||||
selection=[
|
||||
('terminal', "Send to Terminal"),
|
||||
('card', "Manual Card Entry"),
|
||||
],
|
||||
string="Payment Mode",
|
||||
required=True,
|
||||
default='terminal',
|
||||
)
|
||||
|
||||
# --- Terminal fields ---
|
||||
terminal_id = fields.Many2one(
|
||||
'poynt.terminal',
|
||||
string="Terminal",
|
||||
domain="[('provider_id', '=', provider_id), ('active', '=', True)]",
|
||||
)
|
||||
|
||||
# --- Card entry fields (never stored, transient only) ---
|
||||
card_number = fields.Char(string="Card Number")
|
||||
exp_month = fields.Char(string="Exp. Month", size=2)
|
||||
exp_year = fields.Char(string="Exp. Year", size=4)
|
||||
cvv = fields.Char(string="CVV", size=4)
|
||||
cardholder_name = fields.Char(string="Cardholder Name")
|
||||
|
||||
# --- Status tracking for terminal mode ---
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('draft', "Draft"),
|
||||
('waiting', "Waiting for Terminal"),
|
||||
('done', "Payment Collected"),
|
||||
('error', "Error"),
|
||||
],
|
||||
default='draft',
|
||||
)
|
||||
status_message = fields.Text(string="Status", readonly=True)
|
||||
poynt_transaction_ref = fields.Char(readonly=True)
|
||||
poynt_order_id = fields.Char(
|
||||
string="Poynt Order ID",
|
||||
readonly=True,
|
||||
help="The Poynt order UUID created for this payment attempt. "
|
||||
"Used to verify the correct terminal transaction.",
|
||||
)
|
||||
sent_at = fields.Datetime(
|
||||
string="Sent At",
|
||||
readonly=True,
|
||||
help="Timestamp when the payment was sent to the terminal. "
|
||||
"Used to filter out older transactions during status checks.",
|
||||
)
|
||||
transaction_id = fields.Many2one(
|
||||
'payment.transaction',
|
||||
string="Payment Transaction",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
invoice_id = self.env.context.get('active_id')
|
||||
active_model = self.env.context.get('active_model')
|
||||
|
||||
if active_model == 'account.move' and invoice_id:
|
||||
invoice = self.env['account.move'].browse(invoice_id)
|
||||
res['invoice_id'] = invoice.id
|
||||
res['amount'] = invoice.amount_residual
|
||||
res['currency_id'] = invoice.currency_id.id
|
||||
|
||||
provider = self.env['payment.provider'].search([
|
||||
('code', '=', 'poynt'),
|
||||
('state', '!=', 'disabled'),
|
||||
], limit=1)
|
||||
if provider:
|
||||
res['provider_id'] = provider.id
|
||||
if provider.poynt_default_terminal_id:
|
||||
res['terminal_id'] = provider.poynt_default_terminal_id.id
|
||||
|
||||
return res
|
||||
|
||||
@api.onchange('provider_id')
|
||||
def _onchange_provider_id(self):
|
||||
if self.provider_id and self.provider_id.poynt_default_terminal_id:
|
||||
self.terminal_id = self.provider_id.poynt_default_terminal_id
|
||||
|
||||
def action_collect_payment(self):
|
||||
"""Dispatch to the appropriate payment method."""
|
||||
self.ensure_one()
|
||||
|
||||
if self.amount <= 0:
|
||||
raise UserError(_("Payment amount must be greater than zero."))
|
||||
|
||||
self._cleanup_draft_transaction()
|
||||
|
||||
if self.payment_mode == 'terminal':
|
||||
return self._process_terminal_payment()
|
||||
elif self.payment_mode == 'card':
|
||||
return self._process_card_payment()
|
||||
|
||||
def _process_terminal_payment(self):
|
||||
"""Send a payment request to the physical Poynt terminal."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.terminal_id:
|
||||
raise UserError(_("Please select a terminal."))
|
||||
|
||||
tx = self._create_payment_transaction()
|
||||
reference = tx.reference
|
||||
|
||||
try:
|
||||
order_data = self._create_poynt_order(reference)
|
||||
order_id = order_data.get('id', '')
|
||||
tx.poynt_order_id = order_id
|
||||
|
||||
self.terminal_id.action_send_payment_to_terminal(
|
||||
amount=self.amount,
|
||||
currency=self.currency_id,
|
||||
reference=reference,
|
||||
order_id=order_id,
|
||||
)
|
||||
|
||||
self.write({
|
||||
'state': 'waiting',
|
||||
'status_message': _(
|
||||
"Payment request sent to terminal '%(terminal)s'. "
|
||||
"Please complete the transaction on the device.",
|
||||
terminal=self.terminal_id.name,
|
||||
),
|
||||
'poynt_transaction_ref': reference,
|
||||
'poynt_order_id': order_id,
|
||||
'sent_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
return self._open_poll_action()
|
||||
|
||||
except (ValidationError, UserError) as e:
|
||||
self._cleanup_draft_transaction()
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': str(e),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
def _process_card_payment(self):
|
||||
"""Process a manual card entry payment via Poynt Cloud API."""
|
||||
self.ensure_one()
|
||||
self._validate_card_fields()
|
||||
|
||||
tx = self._create_payment_transaction()
|
||||
reference = tx.reference
|
||||
|
||||
try:
|
||||
order_data = self._create_poynt_order(reference)
|
||||
order_id = order_data.get('id', '')
|
||||
tx.poynt_order_id = order_id
|
||||
|
||||
funding_source = {
|
||||
'type': 'CREDIT_DEBIT',
|
||||
'card': {
|
||||
'number': self.card_number.replace(' ', ''),
|
||||
'expirationMonth': int(self.exp_month),
|
||||
'expirationYear': int(self.exp_year),
|
||||
'cardHolderFullName': self.cardholder_name or '',
|
||||
},
|
||||
'verificationData': {
|
||||
'cvData': self.cvv,
|
||||
},
|
||||
'entryDetails': {
|
||||
'customerPresenceStatus': 'MOTO',
|
||||
'entryMode': 'KEYED',
|
||||
},
|
||||
}
|
||||
|
||||
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
||||
minor_amount = poynt_utils.format_poynt_amount(
|
||||
self.amount, self.currency_id,
|
||||
)
|
||||
|
||||
txn_payload = {
|
||||
'action': action,
|
||||
'amounts': {
|
||||
'transactionAmount': minor_amount,
|
||||
'orderAmount': minor_amount,
|
||||
'tipAmount': 0,
|
||||
'cashbackAmount': 0,
|
||||
'currency': self.currency_id.name,
|
||||
},
|
||||
'fundingSource': funding_source,
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
|
||||
'businessId': self.provider_id.poynt_business_id,
|
||||
},
|
||||
'notes': reference,
|
||||
}
|
||||
|
||||
if order_id:
|
||||
txn_payload['references'] = [{
|
||||
'id': order_id,
|
||||
'type': 'POYNT_ORDER',
|
||||
}]
|
||||
|
||||
result = self.provider_id._poynt_make_request(
|
||||
'POST', 'transactions', payload=txn_payload,
|
||||
)
|
||||
|
||||
transaction_id = result.get('id', '')
|
||||
status = result.get('status', '')
|
||||
|
||||
tx.write({
|
||||
'poynt_transaction_id': transaction_id,
|
||||
'provider_reference': transaction_id,
|
||||
})
|
||||
|
||||
payment_data = {
|
||||
'reference': reference,
|
||||
'poynt_transaction_id': transaction_id,
|
||||
'poynt_order_id': order_id,
|
||||
'poynt_status': status,
|
||||
'funding_source': result.get('fundingSource', {}),
|
||||
}
|
||||
tx._process('poynt', payment_data)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Payment collected successfully. Transaction: %(txn_id)s",
|
||||
txn_id=transaction_id,
|
||||
),
|
||||
'poynt_transaction_ref': transaction_id,
|
||||
})
|
||||
|
||||
return self._reopen_wizard()
|
||||
|
||||
except (ValidationError, UserError) as e:
|
||||
self._cleanup_draft_transaction()
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': str(e),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
def action_check_status(self):
|
||||
"""Poll the terminal for payment status (used in waiting state).
|
||||
|
||||
Uses the Poynt order ID to look up transactions associated with
|
||||
the specific order we created for this payment attempt. Falls
|
||||
back to referenceId search with time filtering to avoid matching
|
||||
transactions from previous attempts.
|
||||
|
||||
Returns False if still pending (JS poller keeps going), or an
|
||||
act_window action to show the final result.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.poynt_transaction_ref:
|
||||
raise UserError(_("No payment reference to check."))
|
||||
|
||||
terminal = self.terminal_id
|
||||
if not terminal:
|
||||
raise UserError(_("No terminal associated with this payment."))
|
||||
|
||||
provider = self.provider_id
|
||||
|
||||
try:
|
||||
txn = self._find_terminal_transaction(provider)
|
||||
except (ValidationError, UserError):
|
||||
return False
|
||||
|
||||
if not txn:
|
||||
return False
|
||||
|
||||
status = txn.get('status', 'UNKNOWN')
|
||||
transaction_id = txn.get('id', '')
|
||||
|
||||
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED'):
|
||||
tx = self.transaction_id
|
||||
if tx:
|
||||
tx.write({
|
||||
'poynt_transaction_id': transaction_id,
|
||||
'provider_reference': transaction_id,
|
||||
})
|
||||
payment_data = {
|
||||
'reference': self.poynt_transaction_ref,
|
||||
'poynt_transaction_id': transaction_id,
|
||||
'poynt_status': status,
|
||||
'funding_source': txn.get('fundingSource', {}),
|
||||
}
|
||||
tx._process('poynt', payment_data)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Payment collected successfully on terminal."
|
||||
),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
if status in ('DECLINED', 'VOIDED', 'REFUNDED'):
|
||||
self._cleanup_draft_transaction()
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': _(
|
||||
"Payment was %(status)s on the terminal.",
|
||||
status=status.lower(),
|
||||
),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
return False
|
||||
|
||||
def _find_terminal_transaction(self, provider):
|
||||
"""Locate the Poynt transaction for the current payment attempt.
|
||||
|
||||
Strategy:
|
||||
1. If we have a poynt_order_id, fetch the order from Poynt and
|
||||
check its linked transactions for a completed payment.
|
||||
2. Fall back to searching transactions by referenceId, but only
|
||||
accept transactions created after we sent the cloud message
|
||||
(self.sent_at) to avoid matching older transactions.
|
||||
|
||||
:return: The matching Poynt transaction dict, or None if pending.
|
||||
:rtype: dict | None
|
||||
"""
|
||||
if self.poynt_order_id:
|
||||
txn = self._check_order_transactions(provider)
|
||||
if txn:
|
||||
return txn
|
||||
|
||||
return self._check_by_reference_with_time_filter(provider)
|
||||
|
||||
def _check_order_transactions(self, provider):
|
||||
"""Look up transactions linked to our Poynt order."""
|
||||
try:
|
||||
order_data = provider._poynt_make_request(
|
||||
'GET', f'orders/{self.poynt_order_id}',
|
||||
)
|
||||
except (ValidationError, UserError):
|
||||
_logger.debug("Could not fetch Poynt order %s", self.poynt_order_id)
|
||||
return None
|
||||
|
||||
txn_ids = []
|
||||
for item in order_data.get('transactions', []):
|
||||
tid = item if isinstance(item, str) else item.get('id', '')
|
||||
if tid:
|
||||
txn_ids.append(tid)
|
||||
|
||||
for tid in txn_ids:
|
||||
try:
|
||||
txn_data = provider._poynt_make_request(
|
||||
'GET', f'transactions/{tid}',
|
||||
)
|
||||
status = txn_data.get('status', '')
|
||||
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED',
|
||||
'DECLINED', 'VOIDED', 'REFUNDED'):
|
||||
return txn_data
|
||||
except (ValidationError, UserError):
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _check_by_reference_with_time_filter(self, provider):
|
||||
"""Search transactions by referenceId, filtering by creation time."""
|
||||
sent_at_str = ''
|
||||
if self.sent_at:
|
||||
sent_at_str = self.sent_at.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
params = {
|
||||
'referenceId': self.poynt_transaction_ref,
|
||||
'limit': 5,
|
||||
}
|
||||
if sent_at_str:
|
||||
params['startAt'] = sent_at_str
|
||||
|
||||
try:
|
||||
txn_result = provider._poynt_make_request(
|
||||
'GET', 'transactions', params=params,
|
||||
)
|
||||
except (ValidationError, UserError):
|
||||
return None
|
||||
|
||||
transactions = txn_result.get('transactions', [])
|
||||
if not transactions and not sent_at_str:
|
||||
try:
|
||||
txn_result = provider._poynt_make_request(
|
||||
'GET', 'transactions',
|
||||
params={'notes': self.poynt_transaction_ref, 'limit': 5},
|
||||
)
|
||||
transactions = txn_result.get('transactions', [])
|
||||
except (ValidationError, UserError):
|
||||
return None
|
||||
|
||||
for txn in transactions:
|
||||
status = txn.get('status', 'UNKNOWN')
|
||||
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED',
|
||||
'DECLINED', 'VOIDED', 'REFUNDED'):
|
||||
return txn
|
||||
|
||||
return None
|
||||
|
||||
def action_send_receipt(self):
|
||||
"""Email the payment receipt to the customer and close the wizard."""
|
||||
self.ensure_one()
|
||||
tx = self.transaction_id
|
||||
if not tx:
|
||||
raise UserError(_("No payment transaction found."))
|
||||
|
||||
template = self.env.ref(
|
||||
'fusion_poynt.mail_template_poynt_receipt', raise_if_not_found=False,
|
||||
)
|
||||
if not template:
|
||||
raise UserError(_("Receipt email template not found."))
|
||||
|
||||
template.send_mail(tx.id, force_send=True)
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def action_cancel_payment(self):
|
||||
"""Cancel the payment and clean up the draft transaction."""
|
||||
self.ensure_one()
|
||||
self._cleanup_draft_transaction()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def _cleanup_draft_transaction(self):
|
||||
"""Remove the draft payment transaction created by this wizard."""
|
||||
if not self.transaction_id:
|
||||
return
|
||||
tx = self.transaction_id.sudo()
|
||||
if tx.state == 'draft':
|
||||
tx.invoice_ids = [(5,)]
|
||||
tx.unlink()
|
||||
self.transaction_id = False
|
||||
|
||||
# === HELPERS === #
|
||||
|
||||
def _validate_card_fields(self):
|
||||
"""Validate that card entry fields are properly filled."""
|
||||
if not self.card_number or len(self.card_number.replace(' ', '')) < 13:
|
||||
raise UserError(_("Please enter a valid card number."))
|
||||
if not self.exp_month or not self.exp_month.isdigit():
|
||||
raise UserError(_("Please enter a valid expiry month (01-12)."))
|
||||
if not self.exp_year or not self.exp_year.isdigit() or len(self.exp_year) < 2:
|
||||
raise UserError(_("Please enter a valid expiry year."))
|
||||
if not self.cvv or not self.cvv.isdigit():
|
||||
raise UserError(_("Please enter the CVV."))
|
||||
|
||||
def _create_payment_transaction(self):
|
||||
"""Create a payment.transaction linked to the invoice."""
|
||||
payment_method = self.env['payment.method'].search(
|
||||
[('code', '=', 'card')], limit=1,
|
||||
)
|
||||
if not payment_method:
|
||||
payment_method = self.env['payment.method'].search(
|
||||
[('code', 'in', ('visa', 'mastercard'))], limit=1,
|
||||
)
|
||||
if not payment_method:
|
||||
raise UserError(
|
||||
_("No card payment method found. Please configure one "
|
||||
"in Settings > Payment Methods.")
|
||||
)
|
||||
|
||||
tx_values = {
|
||||
'provider_id': self.provider_id.id,
|
||||
'payment_method_id': payment_method.id,
|
||||
'amount': self.amount,
|
||||
'currency_id': self.currency_id.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
'operation': 'offline',
|
||||
'invoice_ids': [(4, self.invoice_id.id)],
|
||||
}
|
||||
tx = self.env['payment.transaction'].sudo().create(tx_values)
|
||||
self.transaction_id = tx
|
||||
return tx
|
||||
|
||||
def _create_poynt_order(self, reference):
|
||||
"""Create a Poynt order via the API."""
|
||||
order_payload = poynt_utils.build_order_payload(
|
||||
reference,
|
||||
self.amount,
|
||||
self.currency_id,
|
||||
business_id=self.provider_id.poynt_business_id,
|
||||
store_id=self.provider_id.poynt_store_id or '',
|
||||
)
|
||||
return self.provider_id._poynt_make_request(
|
||||
'POST', 'orders', payload=order_payload,
|
||||
)
|
||||
|
||||
def _reopen_wizard(self):
|
||||
"""Return an action that re-opens this wizard record (keeps state)."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Collect Poynt Payment"),
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'views': [(False, 'form')],
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def _open_poll_action(self):
|
||||
"""Return a client action that auto-polls the terminal status."""
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'poynt_poll_action',
|
||||
'name': _("Waiting for Terminal"),
|
||||
'target': 'new',
|
||||
'params': {
|
||||
'wizard_id': self.id,
|
||||
},
|
||||
}
|
||||
135
fusion_poynt/wizard/poynt_payment_wizard_views.xml
Normal file
135
fusion_poynt/wizard/poynt_payment_wizard_views.xml
Normal file
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="poynt_payment_wizard_form" model="ir.ui.view">
|
||||
<field name="name">poynt.payment.wizard.form</field>
|
||||
<field name="model">poynt.payment.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Collect Poynt Payment">
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="poynt_transaction_ref" invisible="1"/>
|
||||
|
||||
<!-- Status banner for waiting / done / error -->
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="state != 'waiting'">
|
||||
<strong>Waiting for terminal...</strong>
|
||||
<field name="status_message" nolabel="1"/>
|
||||
</div>
|
||||
<div class="alert alert-success" role="alert"
|
||||
invisible="state != 'done'">
|
||||
<strong>Payment Collected</strong>
|
||||
<br/>
|
||||
<field name="status_message" nolabel="1"/>
|
||||
</div>
|
||||
<div class="alert alert-danger" role="alert"
|
||||
invisible="state != 'error'">
|
||||
<strong>Error</strong>
|
||||
<br/>
|
||||
<field name="status_message" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<group invisible="state == 'done'">
|
||||
<group string="Payment Details">
|
||||
<field name="invoice_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="amount"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="provider_id"
|
||||
readonly="state != 'draft'"/>
|
||||
</group>
|
||||
<group string="Payment Mode"
|
||||
invisible="state not in ('draft', 'error')">
|
||||
<field name="payment_mode" widget="radio"
|
||||
readonly="state not in ('draft', 'error')"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Terminal section -->
|
||||
<group string="Terminal"
|
||||
invisible="payment_mode != 'terminal' or state == 'done'">
|
||||
<field name="terminal_id"
|
||||
required="payment_mode == 'terminal' and state in ('draft', 'error')"
|
||||
readonly="state == 'waiting'"/>
|
||||
</group>
|
||||
|
||||
<!-- Card entry section -->
|
||||
<group string="Card Details"
|
||||
invisible="payment_mode != 'card' or state == 'done'">
|
||||
<group>
|
||||
<field name="card_number"
|
||||
placeholder="4111 1111 1111 1111"
|
||||
required="payment_mode == 'card' and state in ('draft', 'error')"
|
||||
password="True"/>
|
||||
<field name="cardholder_name"
|
||||
placeholder="Name on card"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="exp_month"
|
||||
placeholder="MM"
|
||||
required="payment_mode == 'card' and state in ('draft', 'error')"/>
|
||||
<field name="exp_year"
|
||||
placeholder="YYYY"
|
||||
required="payment_mode == 'card' and state in ('draft', 'error')"/>
|
||||
<field name="cvv"
|
||||
placeholder="123"
|
||||
required="payment_mode == 'card' and state in ('draft', 'error')"
|
||||
password="True"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<footer>
|
||||
<!-- Draft / Error state: show action buttons -->
|
||||
<button string="Send to Terminal"
|
||||
name="action_collect_payment"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="payment_mode != 'terminal' or state not in ('draft', 'error')"
|
||||
data-hotkey="q"/>
|
||||
<button string="Collect Payment"
|
||||
name="action_collect_payment"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="payment_mode != 'card' or state not in ('draft', 'error')"
|
||||
data-hotkey="q"/>
|
||||
|
||||
<!-- Waiting state: check status + cancel -->
|
||||
<button string="Check Status"
|
||||
name="action_check_status"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="state != 'waiting'"
|
||||
data-hotkey="q"/>
|
||||
<button string="Cancel Payment"
|
||||
name="action_cancel_payment"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
invisible="state not in ('waiting', 'error')"
|
||||
data-hotkey="x"/>
|
||||
|
||||
<!-- Done state: send receipt + close -->
|
||||
<button string="Send Receipt"
|
||||
name="action_send_receipt"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
icon="fa-envelope"
|
||||
invisible="state != 'done'"
|
||||
data-hotkey="s"/>
|
||||
<button string="Close"
|
||||
class="btn-secondary"
|
||||
special="cancel"
|
||||
invisible="state != 'done'"
|
||||
data-hotkey="x"/>
|
||||
|
||||
<!-- Draft state: cancel cleans up -->
|
||||
<button string="Cancel"
|
||||
name="action_cancel_payment"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
invisible="state != 'draft'"
|
||||
data-hotkey="x"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
531
fusion_poynt/wizard/poynt_refund_wizard.py
Normal file
531
fusion_poynt/wizard/poynt_refund_wizard.py
Normal file
@@ -0,0 +1,531 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.fusion_poynt import utils as poynt_utils
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
REFERENCED_REFUND_LIMIT_DAYS = 180
|
||||
|
||||
|
||||
class PoyntRefundWizard(models.TransientModel):
|
||||
_name = 'poynt.refund.wizard'
|
||||
_description = 'Refund via Poynt'
|
||||
|
||||
credit_note_id = fields.Many2one(
|
||||
'account.move',
|
||||
string="Credit Note",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
original_invoice_id = fields.Many2one(
|
||||
'account.move',
|
||||
string="Original Invoice",
|
||||
readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='credit_note_id.partner_id',
|
||||
string="Customer",
|
||||
)
|
||||
amount = fields.Monetary(
|
||||
string="Refund Amount",
|
||||
required=True,
|
||||
currency_field='currency_id',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string="Currency",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
provider_id = fields.Many2one(
|
||||
'payment.provider',
|
||||
string="Poynt Provider",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
original_transaction_id = fields.Many2one(
|
||||
'payment.transaction',
|
||||
string="Original Transaction",
|
||||
readonly=True,
|
||||
)
|
||||
original_poynt_txn_id = fields.Char(
|
||||
string="Poynt Transaction ID",
|
||||
readonly=True,
|
||||
)
|
||||
card_info = fields.Char(
|
||||
string="Card Used",
|
||||
readonly=True,
|
||||
)
|
||||
transaction_age_days = fields.Integer(
|
||||
string="Transaction Age (days)",
|
||||
readonly=True,
|
||||
)
|
||||
refund_type = fields.Selection(
|
||||
selection=[
|
||||
('referenced', "Referenced Refund"),
|
||||
('non_referenced', "Non-Referenced Credit"),
|
||||
],
|
||||
string="Refund Method",
|
||||
readonly=True,
|
||||
)
|
||||
refund_type_note = fields.Text(
|
||||
string="Note",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
terminal_id = fields.Many2one(
|
||||
'poynt.terminal',
|
||||
string="Terminal",
|
||||
domain="[('provider_id', '=', provider_id), ('active', '=', True)]",
|
||||
help="Terminal to process the non-referenced credit on.",
|
||||
)
|
||||
|
||||
refund_transaction_id = fields.Many2one(
|
||||
'payment.transaction',
|
||||
string="Refund Transaction",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('confirm', "Confirm"),
|
||||
('done', "Refunded"),
|
||||
('error', "Error"),
|
||||
],
|
||||
default='confirm',
|
||||
)
|
||||
status_message = fields.Text(string="Status", readonly=True)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
credit_note_id = self.env.context.get('active_id')
|
||||
active_model = self.env.context.get('active_model')
|
||||
|
||||
if active_model != 'account.move' or not credit_note_id:
|
||||
return res
|
||||
|
||||
credit_note = self.env['account.move'].browse(credit_note_id)
|
||||
res['credit_note_id'] = credit_note.id
|
||||
res['amount'] = abs(credit_note.amount_residual) or abs(credit_note.amount_total)
|
||||
res['currency_id'] = credit_note.currency_id.id
|
||||
|
||||
orig_tx = credit_note._get_original_poynt_transaction()
|
||||
if not orig_tx:
|
||||
raise UserError(_(
|
||||
"No Poynt payment transaction found for the original invoice. "
|
||||
"This credit note cannot be refunded via Poynt."
|
||||
))
|
||||
|
||||
res['original_transaction_id'] = orig_tx.id
|
||||
res['provider_id'] = orig_tx.provider_id.id
|
||||
res['original_invoice_id'] = credit_note.reversed_entry_id.id
|
||||
res['original_poynt_txn_id'] = orig_tx.poynt_transaction_id
|
||||
|
||||
if orig_tx.provider_id.poynt_default_terminal_id:
|
||||
res['terminal_id'] = orig_tx.provider_id.poynt_default_terminal_id.id
|
||||
|
||||
age_days = 0
|
||||
if orig_tx.create_date:
|
||||
age_days = (fields.Datetime.now() - orig_tx.create_date).days
|
||||
res['transaction_age_days'] = age_days
|
||||
|
||||
if age_days > REFERENCED_REFUND_LIMIT_DAYS:
|
||||
res['refund_type'] = 'non_referenced'
|
||||
res['refund_type_note'] = _(
|
||||
"This transaction is %(days)s days old (limit is %(limit)s "
|
||||
"days). A non-referenced credit will be issued. This "
|
||||
"requires the customer's card to be present on the terminal.",
|
||||
days=age_days,
|
||||
limit=REFERENCED_REFUND_LIMIT_DAYS,
|
||||
)
|
||||
else:
|
||||
res['refund_type'] = 'referenced'
|
||||
res['refund_type_note'] = _(
|
||||
"This transaction is %(days)s days old (within the %(limit)s-day "
|
||||
"limit). A referenced refund will be issued back to the "
|
||||
"original card automatically.",
|
||||
days=age_days,
|
||||
limit=REFERENCED_REFUND_LIMIT_DAYS,
|
||||
)
|
||||
|
||||
receipt_data = orig_tx.poynt_receipt_data
|
||||
if receipt_data:
|
||||
try:
|
||||
data = json.loads(receipt_data)
|
||||
card_type = data.get('card_type', '')
|
||||
card_last4 = data.get('card_last4', '')
|
||||
if card_type or card_last4:
|
||||
res['card_info'] = f"{card_type} ****{card_last4}"
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
def action_process_refund(self):
|
||||
"""Dispatch to referenced refund or non-referenced credit."""
|
||||
self.ensure_one()
|
||||
|
||||
if self.amount <= 0:
|
||||
raise UserError(_("Refund amount must be greater than zero."))
|
||||
|
||||
orig_tx = self.original_transaction_id
|
||||
if orig_tx.poynt_voided:
|
||||
raise UserError(_(
|
||||
"This transaction was already voided on Poynt on %(date)s. "
|
||||
"A voided transaction cannot also be refunded -- the charge "
|
||||
"was already reversed before settlement.",
|
||||
date=orig_tx.poynt_void_date,
|
||||
))
|
||||
|
||||
self._verify_transaction_not_already_reversed()
|
||||
|
||||
if self.refund_type == 'non_referenced':
|
||||
return self._process_non_referenced_credit()
|
||||
return self._process_referenced_refund()
|
||||
|
||||
def _verify_transaction_not_already_reversed(self):
|
||||
"""Check on Poynt that the transaction and all linked children
|
||||
have not been voided or refunded.
|
||||
|
||||
For SALE transactions Poynt creates AUTHORIZE + CAPTURE children.
|
||||
A void/refund may target the capture child, leaving the parent
|
||||
still showing ``status: CAPTURED``. We must check the full chain.
|
||||
"""
|
||||
orig_tx = self.original_transaction_id
|
||||
provider = self.provider_id
|
||||
txn_id = orig_tx.poynt_transaction_id
|
||||
|
||||
try:
|
||||
txn_data = provider._poynt_make_request(
|
||||
'GET', f'transactions/{txn_id}',
|
||||
)
|
||||
except (ValidationError, Exception):
|
||||
_logger.debug("Could not verify transaction %s on Poynt", txn_id)
|
||||
return
|
||||
|
||||
self._check_txn_reversed(txn_data, orig_tx)
|
||||
|
||||
for link in txn_data.get('links', []):
|
||||
child_id = link.get('href', '')
|
||||
if not child_id:
|
||||
continue
|
||||
try:
|
||||
child_data = provider._poynt_make_request(
|
||||
'GET', f'transactions/{child_id}',
|
||||
)
|
||||
self._check_txn_reversed(child_data, orig_tx)
|
||||
except (ValidationError, Exception):
|
||||
continue
|
||||
|
||||
def _check_txn_reversed(self, txn_data, orig_tx):
|
||||
"""Raise if the given Poynt transaction has been voided or refunded."""
|
||||
txn_id = txn_data.get('id', '?')
|
||||
|
||||
if txn_data.get('voided'):
|
||||
_logger.warning(
|
||||
"Poynt txn %s is voided, blocking refund", txn_id,
|
||||
)
|
||||
if not orig_tx.poynt_voided:
|
||||
orig_tx.sudo().write({
|
||||
'state': 'cancel',
|
||||
'poynt_voided': True,
|
||||
'poynt_void_date': fields.Datetime.now(),
|
||||
})
|
||||
raise UserError(_(
|
||||
"This transaction (%(txn_id)s) has already been voided on "
|
||||
"Poynt. The charge was reversed before settlement -- no "
|
||||
"refund is needed.",
|
||||
txn_id=txn_id,
|
||||
))
|
||||
|
||||
status = txn_data.get('status', '')
|
||||
if status == 'REFUNDED':
|
||||
_logger.warning(
|
||||
"Poynt txn %s is already refunded, blocking duplicate", txn_id,
|
||||
)
|
||||
raise UserError(_(
|
||||
"This transaction (%(txn_id)s) has already been refunded on "
|
||||
"Poynt. A duplicate refund cannot be issued.",
|
||||
txn_id=txn_id,
|
||||
))
|
||||
|
||||
if status == 'VOIDED':
|
||||
_logger.warning(
|
||||
"Poynt txn %s has VOIDED status, blocking refund", txn_id,
|
||||
)
|
||||
if not orig_tx.poynt_voided:
|
||||
orig_tx.sudo().write({
|
||||
'state': 'cancel',
|
||||
'poynt_voided': True,
|
||||
'poynt_void_date': fields.Datetime.now(),
|
||||
})
|
||||
raise UserError(_(
|
||||
"This transaction (%(txn_id)s) has already been voided on "
|
||||
"Poynt. The charge was reversed before settlement -- no "
|
||||
"refund is needed.",
|
||||
txn_id=txn_id,
|
||||
))
|
||||
|
||||
def _process_referenced_refund(self):
|
||||
"""Send a referenced REFUND using the original transaction's parentId."""
|
||||
orig_tx = self.original_transaction_id
|
||||
provider = self.provider_id
|
||||
|
||||
parent_txn_id = orig_tx.poynt_transaction_id
|
||||
try:
|
||||
txn_data = provider._poynt_make_request(
|
||||
'GET', f'transactions/{parent_txn_id}',
|
||||
)
|
||||
for link in txn_data.get('links', []):
|
||||
if link.get('rel') == 'CAPTURE' and link.get('href'):
|
||||
parent_txn_id = link['href']
|
||||
_logger.info(
|
||||
"Refund: using captureId %s instead of original %s",
|
||||
parent_txn_id, orig_tx.poynt_transaction_id,
|
||||
)
|
||||
break
|
||||
except (ValidationError, Exception):
|
||||
_logger.debug(
|
||||
"Could not fetch parent txn %s, using original ID",
|
||||
parent_txn_id,
|
||||
)
|
||||
|
||||
minor_amount = poynt_utils.format_poynt_amount(
|
||||
self.amount, self.currency_id,
|
||||
)
|
||||
|
||||
refund_payload = {
|
||||
'action': 'REFUND',
|
||||
'parentId': parent_txn_id,
|
||||
'fundingSource': {
|
||||
'type': 'CREDIT_DEBIT',
|
||||
},
|
||||
'amounts': {
|
||||
'transactionAmount': minor_amount,
|
||||
'orderAmount': minor_amount,
|
||||
'currency': self.currency_id.name,
|
||||
},
|
||||
'context': {
|
||||
'source': 'WEB',
|
||||
'sourceApp': 'odoo.fusion_poynt',
|
||||
},
|
||||
'notes': f'Refund for {orig_tx.reference} via {self.credit_note_id.name}',
|
||||
}
|
||||
|
||||
try:
|
||||
result = provider._poynt_make_request(
|
||||
'POST', 'transactions', payload=refund_payload,
|
||||
)
|
||||
except (ValidationError, UserError) as e:
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': str(e),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
return self._handle_refund_result(result, orig_tx)
|
||||
|
||||
def _process_non_referenced_credit(self):
|
||||
"""Send a non-referenced credit via cloud message to the terminal.
|
||||
|
||||
Required when the original transaction is older than 180 days.
|
||||
The customer's card must be present on the terminal.
|
||||
"""
|
||||
if not self.terminal_id:
|
||||
raise UserError(_(
|
||||
"A terminal is required for non-referenced credits. "
|
||||
"The customer's card must be present on the device."
|
||||
))
|
||||
|
||||
provider = self.provider_id
|
||||
orig_tx = self.original_transaction_id
|
||||
minor_amount = poynt_utils.format_poynt_amount(
|
||||
self.amount, self.currency_id,
|
||||
)
|
||||
|
||||
reference = f"NRC-{self.credit_note_id.name}"
|
||||
|
||||
payment_data = json.dumps({
|
||||
'amount': minor_amount,
|
||||
'currency': self.currency_id.name,
|
||||
'nonReferencedCredit': True,
|
||||
'referenceId': reference,
|
||||
'callbackUrl': '',
|
||||
})
|
||||
|
||||
cloud_payload = {
|
||||
'ttl': 120,
|
||||
'businessId': provider.poynt_business_id,
|
||||
'storeId': provider.poynt_store_id,
|
||||
'deviceId': self.terminal_id.terminal_id,
|
||||
'data': json.dumps({
|
||||
'action': 'non-referenced-credit',
|
||||
'purchaseAmount': minor_amount,
|
||||
'currency': self.currency_id.name,
|
||||
'referenceId': reference,
|
||||
'payment': payment_data,
|
||||
}),
|
||||
}
|
||||
|
||||
try:
|
||||
provider._poynt_make_request(
|
||||
'POST', 'cloudMessages',
|
||||
payload=cloud_payload,
|
||||
business_scoped=False,
|
||||
)
|
||||
except (ValidationError, UserError) as e:
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': str(e),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
refund_tx = self._create_refund_transaction(
|
||||
orig_tx,
|
||||
refund_txn_id='',
|
||||
refund_status='PENDING',
|
||||
)
|
||||
|
||||
self.credit_note_id.sudo().poynt_refunded = True
|
||||
|
||||
self.credit_note_id.sudo().message_post(
|
||||
body=_(
|
||||
"Non-referenced credit sent to terminal '%(terminal)s'. "
|
||||
"Amount: %(amount)s %(currency)s. "
|
||||
"The customer must present their card on the terminal to "
|
||||
"complete the refund.",
|
||||
terminal=self.terminal_id.name,
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
),
|
||||
)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Non-referenced credit of %(amount)s %(currency)s sent to "
|
||||
"terminal '%(terminal)s'. Please ask the customer to present "
|
||||
"their card on the terminal to complete the refund.",
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
terminal=self.terminal_id.name,
|
||||
),
|
||||
})
|
||||
|
||||
return self._reopen_wizard()
|
||||
|
||||
def _handle_refund_result(self, result, orig_tx):
|
||||
"""Process the Poynt API response for a referenced refund."""
|
||||
refund_status = result.get('status', '')
|
||||
refund_txn_id = result.get('id', '')
|
||||
|
||||
_logger.info(
|
||||
"Poynt refund response: status=%s, id=%s",
|
||||
refund_status, refund_txn_id,
|
||||
)
|
||||
|
||||
if refund_status in ('DECLINED', 'FAILED'):
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': _(
|
||||
"Refund declined by the payment processor. "
|
||||
"Status: %(status)s. Please try again or contact support.",
|
||||
status=refund_status,
|
||||
),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
refund_tx = self._create_refund_transaction(orig_tx, refund_txn_id, refund_status)
|
||||
self.refund_transaction_id = refund_tx
|
||||
|
||||
self.credit_note_id.sudo().poynt_refunded = True
|
||||
|
||||
self.credit_note_id.sudo().message_post(
|
||||
body=_(
|
||||
"Refund processed via Poynt. Amount: %(amount)s %(currency)s. "
|
||||
"Poynt Transaction ID: %(txn_id)s.",
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
txn_id=refund_txn_id,
|
||||
),
|
||||
)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Refund of %(amount)s %(currency)s processed successfully. "
|
||||
"The refund will appear on the customer's card within "
|
||||
"3-5 business days.",
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
),
|
||||
})
|
||||
|
||||
return self._reopen_wizard()
|
||||
|
||||
def _create_refund_transaction(self, orig_tx, refund_txn_id, refund_status):
|
||||
"""Create a payment.transaction for the refund."""
|
||||
payment_method = self.env['payment.method'].search(
|
||||
[('code', '=', 'card')], limit=1,
|
||||
) or self.env['payment.method'].search(
|
||||
[('code', 'in', ('visa', 'mastercard'))], limit=1,
|
||||
)
|
||||
|
||||
refund_tx = self.env['payment.transaction'].sudo().create({
|
||||
'provider_id': self.provider_id.id,
|
||||
'payment_method_id': payment_method.id if payment_method else False,
|
||||
'amount': -self.amount,
|
||||
'currency_id': self.currency_id.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
'operation': 'refund',
|
||||
'source_transaction_id': orig_tx.id,
|
||||
'provider_reference': refund_txn_id or '',
|
||||
'poynt_transaction_id': refund_txn_id or '',
|
||||
'invoice_ids': [(4, self.credit_note_id.id)],
|
||||
})
|
||||
|
||||
if refund_txn_id and refund_status not in ('PENDING',):
|
||||
payment_data = {
|
||||
'reference': refund_tx.reference,
|
||||
'poynt_transaction_id': refund_txn_id,
|
||||
'poynt_status': refund_status,
|
||||
}
|
||||
refund_tx._process('poynt', payment_data)
|
||||
|
||||
return refund_tx
|
||||
|
||||
def action_send_receipt(self):
|
||||
"""Email the refund receipt to the customer and close the wizard."""
|
||||
self.ensure_one()
|
||||
tx = self.refund_transaction_id
|
||||
if not tx:
|
||||
raise UserError(_("No refund transaction found."))
|
||||
|
||||
template = self.env.ref(
|
||||
'fusion_poynt.mail_template_poynt_receipt', raise_if_not_found=False,
|
||||
)
|
||||
if not template:
|
||||
raise UserError(_("Receipt email template not found."))
|
||||
|
||||
template.send_mail(tx.id, force_send=True)
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def _reopen_wizard(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Refund via Poynt"),
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'views': [(False, 'form')],
|
||||
'target': 'new',
|
||||
}
|
||||
112
fusion_poynt/wizard/poynt_refund_wizard_views.xml
Normal file
112
fusion_poynt/wizard/poynt_refund_wizard_views.xml
Normal file
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="poynt_refund_wizard_form" model="ir.ui.view">
|
||||
<field name="name">poynt.refund.wizard.form</field>
|
||||
<field name="model">poynt.refund.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Refund via Poynt">
|
||||
|
||||
<!-- Success banner -->
|
||||
<div class="alert alert-success text-center"
|
||||
role="status"
|
||||
invisible="state != 'done'">
|
||||
<strong>Refund Processed Successfully</strong>
|
||||
<p><field name="status_message" nolabel="1" readonly="1"/></p>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<div class="alert alert-danger text-center"
|
||||
role="alert"
|
||||
invisible="state != 'error'">
|
||||
<strong>Refund Failed</strong>
|
||||
<p><field name="status_message" nolabel="1" readonly="1"/></p>
|
||||
</div>
|
||||
|
||||
<!-- Non-referenced credit warning -->
|
||||
<div class="alert alert-warning text-center"
|
||||
role="alert"
|
||||
invisible="state != 'confirm' or refund_type != 'non_referenced'">
|
||||
<strong>Non-Referenced Credit Required</strong>
|
||||
<p>
|
||||
The original transaction is over 180 days old.
|
||||
The customer's card must be physically present on the
|
||||
terminal to process this refund.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<group invisible="state == 'done'">
|
||||
<group string="Refund Details">
|
||||
<field name="credit_note_id" readonly="1"/>
|
||||
<field name="original_invoice_id" readonly="1"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="amount" readonly="state != 'confirm'"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
<group string="Original Payment">
|
||||
<field name="provider_id" readonly="1"/>
|
||||
<field name="original_transaction_id" readonly="1"/>
|
||||
<field name="original_poynt_txn_id" readonly="1"/>
|
||||
<field name="card_info" readonly="1"
|
||||
invisible="not card_info"/>
|
||||
<field name="transaction_age_days" readonly="1"/>
|
||||
<field name="refund_type" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Terminal selector for non-referenced credits -->
|
||||
<group invisible="state != 'confirm' or refund_type != 'non_referenced'">
|
||||
<group string="Terminal">
|
||||
<field name="terminal_id"
|
||||
required="refund_type == 'non_referenced'"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Refund method note -->
|
||||
<group invisible="state != 'confirm'">
|
||||
<field name="refund_type_note" readonly="1" nolabel="1"
|
||||
widget="text" colspan="2"/>
|
||||
</group>
|
||||
|
||||
<footer>
|
||||
<!-- Confirm state -->
|
||||
<button string="Process Refund"
|
||||
name="action_process_refund"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
icon="fa-undo"
|
||||
invisible="state != 'confirm'"
|
||||
confirm="Are you sure you want to refund this amount? This cannot be undone."
|
||||
data-hotkey="q"/>
|
||||
<button string="Cancel"
|
||||
class="btn-secondary"
|
||||
special="cancel"
|
||||
invisible="state != 'confirm'"
|
||||
data-hotkey="x"/>
|
||||
<!-- Done state -->
|
||||
<button string="Send Receipt"
|
||||
name="action_send_receipt"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
icon="fa-envelope"
|
||||
invisible="state != 'done'"
|
||||
data-hotkey="s"/>
|
||||
<button string="Close"
|
||||
class="btn-secondary"
|
||||
special="cancel"
|
||||
invisible="state != 'done'"
|
||||
data-hotkey="x"/>
|
||||
<!-- Error state -->
|
||||
<button string="Close"
|
||||
class="btn-primary"
|
||||
special="cancel"
|
||||
invisible="state != 'error'"
|
||||
data-hotkey="x"/>
|
||||
</footer>
|
||||
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
3
fusion_rental/__init__.py
Normal file
3
fusion_rental/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
43
fusion_rental/__manifest__.py
Normal file
43
fusion_rental/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
fusion_rental/controllers/__init__.py
Normal file
1
fusion_rental/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
305
fusion_rental/controllers/main.py
Normal file
305
fusion_rental/controllers/main.py
Normal 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},
|
||||
)
|
||||
53
fusion_rental/data/ir_cron_data.xml
Normal file
53
fusion_rental/data/ir_cron_data.xml
Normal 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>
|
||||
23
fusion_rental/data/loyalty_program_data.xml
Normal file
23
fusion_rental/data/loyalty_program_data.xml
Normal 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>
|
||||
616
fusion_rental/data/mail_template_data.xml
Normal file
616
fusion_rental/data/mail_template_data.xml
Normal 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 & 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 & 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 & 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 & 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 & 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 & 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 && !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 & Authorize Card';
|
||||
}
|
||||
}).catch(function(e) {
|
||||
errDiv.textContent = 'Network error. Please try again.';
|
||||
errDiv.classList.remove('d-none');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sign Agreement & 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>
|
||||
18
fusion_rental/data/product_data.xml
Normal file
18
fusion_rental/data/product_data.xml
Normal 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>
|
||||
6
fusion_rental/models/__init__.py
Normal file
6
fusion_rental/models/__init__.py
Normal 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
|
||||
123
fusion_rental/models/cancellation_request.py
Normal file
123
fusion_rental/models/cancellation_request.py
Normal 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)
|
||||
74
fusion_rental/models/renewal_log.py
Normal file
74
fusion_rental/models/renewal_log.py
Normal 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}"
|
||||
)
|
||||
19
fusion_rental/models/res_config_settings.py
Normal file
19
fusion_rental/models/res_config_settings.py
Normal 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.",
|
||||
)
|
||||
1040
fusion_rental/models/sale_order.py
Normal file
1040
fusion_rental/models/sale_order.py
Normal file
File diff suppressed because it is too large
Load Diff
63
fusion_rental/models/sale_order_line.py
Normal file
63
fusion_rental/models/sale_order_line.py
Normal 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()
|
||||
10
fusion_rental/models/stock_warehouse.py
Normal file
10
fusion_rental/models/stock_warehouse.py
Normal 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.",
|
||||
)
|
||||
399
fusion_rental/report/report_rental_agreement.xml
Normal file
399
fusion_rental/report/report_rental_agreement.xml
Normal 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 & 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>
|
||||
9
fusion_rental/security/ir.model.access.csv
Normal file
9
fusion_rental/security/ir.model.access.csv
Normal 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
|
||||
|
30
fusion_rental/security/security.xml
Normal file
30
fusion_rental/security/security.xml
Normal 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>
|
||||
BIN
fusion_rental/static/description/icon.png
Normal file
BIN
fusion_rental/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
89
fusion_rental/views/cancellation_request_views.xml
Normal file
89
fusion_rental/views/cancellation_request_views.xml
Normal 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>
|
||||
34
fusion_rental/views/menus.xml
Normal file
34
fusion_rental/views/menus.xml
Normal 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>
|
||||
146
fusion_rental/views/portal_rental_inspection.xml
Normal file
146
fusion_rental/views/portal_rental_inspection.xml
Normal 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 < 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>
|
||||
53
fusion_rental/views/product_template_views.xml
Normal file
53
fusion_rental/views/product_template_views.xml
Normal 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>
|
||||
85
fusion_rental/views/renewal_log_views.xml
Normal file
85
fusion_rental/views/renewal_log_views.xml
Normal 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>
|
||||
39
fusion_rental/views/res_config_settings_views.xml
Normal file
39
fusion_rental/views/res_config_settings_views.xml
Normal 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 > Configuration > 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>
|
||||
202
fusion_rental/views/sale_order_views.xml
Normal file
202
fusion_rental/views/sale_order_views.xml
Normal 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>
|
||||
2
fusion_rental/wizard/__init__.py
Normal file
2
fusion_rental/wizard/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import manual_renewal_wizard
|
||||
from . import deposit_deduction_wizard
|
||||
64
fusion_rental/wizard/deposit_deduction_wizard.py
Normal file
64
fusion_rental/wizard/deposit_deduction_wizard.py
Normal 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'}
|
||||
36
fusion_rental/wizard/deposit_deduction_wizard_views.xml
Normal file
36
fusion_rental/wizard/deposit_deduction_wizard_views.xml
Normal 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>
|
||||
101
fusion_rental/wizard/manual_renewal_wizard.py
Normal file
101
fusion_rental/wizard/manual_renewal_wizard.py
Normal 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'}
|
||||
46
fusion_rental/wizard/manual_renewal_wizard_views.xml
Normal file
46
fusion_rental/wizard/manual_renewal_wizard_views.xml
Normal 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 & Collect Payment"
|
||||
class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -376,6 +376,34 @@ class RcConfig(models.Model):
|
||||
"""Return the first connected config, or False."""
|
||||
return self.search([('state', '=', 'connected')], limit=1)
|
||||
|
||||
def _send_sms(self, to_number, message_text, from_number=None):
|
||||
"""Send an SMS via the RingCentral REST API.
|
||||
|
||||
:param str to_number: Recipient phone number (E.164 format).
|
||||
:param str message_text: SMS body (max 1000 characters).
|
||||
:param str from_number: Sender phone number. Defaults to the
|
||||
company phone configured in Odoo.
|
||||
:return: API response dict.
|
||||
:raises UserError: If the RingCentral config is not connected.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not from_number:
|
||||
from_number = self.env.company.phone
|
||||
if not from_number:
|
||||
raise UserError(
|
||||
_('No sender phone number configured. Set a phone number '
|
||||
'on your company or pass from_number explicitly.')
|
||||
)
|
||||
payload = {
|
||||
'to': [{'phoneNumber': to_number}],
|
||||
'from': {'phoneNumber': from_number},
|
||||
'text': message_text[:1000],
|
||||
}
|
||||
return self._api_post(
|
||||
'/restapi/v1.0/account/~/extension/~/sms',
|
||||
data=payload,
|
||||
)
|
||||
|
||||
def action_rematch_contacts(self):
|
||||
"""Re-run contact matching on all calls without a linked partner."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_rc_config_manager,rc.config.manager,model_rc_config,group_rc_manager,1,1,1,1
|
||||
access_rc_call_history_base,rc.call.history.base.read,model_rc_call_history,base.group_user,1,0,0,0
|
||||
access_rc_call_history_user,rc.call.history.user,model_rc_call_history,group_rc_user,1,1,1,0
|
||||
access_rc_call_history_company,rc.call.history.company,model_rc_call_history,group_rc_company_user,1,1,1,0
|
||||
access_rc_call_history_manager,rc.call.history.manager,model_rc_call_history,group_rc_manager,1,1,1,1
|
||||
access_rc_call_dashboard_user,rc.call.dashboard.user,model_rc_call_dashboard,group_rc_user,1,1,1,1
|
||||
access_rc_voicemail_base,rc.voicemail.base.read,model_rc_voicemail,base.group_user,1,0,0,0
|
||||
access_rc_voicemail_user,rc.voicemail.user,model_rc_voicemail,group_rc_user,1,1,1,0
|
||||
access_rc_voicemail_company,rc.voicemail.company,model_rc_voicemail,group_rc_company_user,1,1,1,0
|
||||
access_rc_voicemail_manager,rc.voicemail.manager,model_rc_voicemail,group_rc_manager,1,1,1,1
|
||||
|
||||
|
@@ -50,6 +50,33 @@
|
||||
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Base read rules (allow computed fields on res.partner to -->
|
||||
<!-- work for users without RC groups) -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_call_base_read" model="ir.rule">
|
||||
<field name="name">RC Call: all internal users read-only</field>
|
||||
<field name="model_id" ref="model_rc_call_history"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_voicemail_base_read" model="ir.rule">
|
||||
<field name="name">RC Voicemail: all internal users read-only</field>
|
||||
<field name="model_id" ref="model_rc_voicemail"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Call History record rules -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
@@ -8,34 +8,39 @@
|
||||
<field name="inherit_id" ref="fusion_faxes.view_fusion_fax_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Add Forward and Send New buttons to header -->
|
||||
<!-- Add Forward and Send New buttons to header (RC users only) -->
|
||||
<xpath expr="//button[@name='action_resend']" position="after">
|
||||
<button name="action_forward_fax" string="Forward Fax"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-share"
|
||||
invisible="direction != 'inbound' or state != 'received'"/>
|
||||
invisible="direction != 'inbound' or state != 'received'"
|
||||
groups="fusion_ringcentral.group_rc_user"/>
|
||||
<button name="action_send_new_fax" string="Send New Fax"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-fax"/>
|
||||
icon="fa-fax"
|
||||
groups="fusion_ringcentral.group_rc_user"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Add Contact, Sales Orders, Invoices smart buttons -->
|
||||
<!-- Add Contact, Sales Orders, Invoices smart buttons (RC users only) -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_contact" type="object"
|
||||
class="oe_stat_button" icon="fa-user"
|
||||
invisible="not partner_id">
|
||||
invisible="not partner_id"
|
||||
groups="fusion_ringcentral.group_rc_user">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text"><field name="partner_id" readonly="1" nolabel="1" class="o_text_overflow"/></span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_sale_orders" type="object"
|
||||
class="oe_stat_button" icon="fa-shopping-cart"
|
||||
invisible="not partner_id">
|
||||
invisible="not partner_id"
|
||||
groups="fusion_ringcentral.group_rc_user">
|
||||
<field name="sale_order_count" widget="statinfo" string="Sales Orders"/>
|
||||
</button>
|
||||
<button name="action_view_invoices" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="not partner_id">
|
||||
invisible="not partner_id"
|
||||
groups="fusion_ringcentral.group_rc_user">
|
||||
<field name="invoice_count" widget="statinfo" string="Invoices"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Fusion RingCentral" string="Fusion RingCentral" name="fusion_ringcentral">
|
||||
<app data-string="Fusion RingCentral" string="Fusion RingCentral" name="fusion_ringcentral"
|
||||
groups="fusion_ringcentral.group_rc_manager">
|
||||
<h2>AI Transcription</h2>
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
|
||||
@@ -8,19 +8,21 @@
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Smart button for call count -->
|
||||
<!-- Smart button for call count (RC users only) -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_rc_calls" type="object"
|
||||
class="oe_stat_button" icon="fa-phone"
|
||||
invisible="rc_call_count == 0">
|
||||
invisible="rc_call_count == 0"
|
||||
groups="fusion_ringcentral.group_rc_user">
|
||||
<field name="rc_call_count" widget="statinfo" string="Calls"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- RingCentral Calls tab -->
|
||||
<!-- RingCentral Calls tab (RC users only) -->
|
||||
<xpath expr="//page[@name='internal_notes']" position="after">
|
||||
<page string="RingCentral Calls" name="rc_calls"
|
||||
invisible="rc_call_count == 0">
|
||||
invisible="rc_call_count == 0"
|
||||
groups="fusion_ringcentral.group_rc_user">
|
||||
<field name="rc_call_ids" readonly="1">
|
||||
<list decoration-danger="status in ('missed', 'no_answer')"
|
||||
decoration-success="status == 'answered'">
|
||||
|
||||
Reference in New Issue
Block a user