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

View File

@@ -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.',
}

View File

@@ -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': """

View File

@@ -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.',
)

View File

@@ -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:

View File

@@ -270,6 +270,10 @@
<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>
@@ -277,6 +281,7 @@
<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;">
<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>

View 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)

View 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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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/>

View File

@@ -2,6 +2,7 @@
from . import controllers
from . import models
from . import wizard
def post_init_hook(env):

View File

@@ -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',

View File

@@ -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>

View 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 &lt; 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>

View File

@@ -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

View 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

View File

@@ -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',

View File

@@ -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 {}

View File

@@ -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
try:
result = self.provider_id._poynt_make_request(
'POST',
f'cloudMessages',
payload={
'deviceId': self.device_id,
'ttl': 300,
'serialNum': self.serial_number or '',
'data': {
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',
'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': 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'),

View 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,
},
}

View 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>

View 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 &lt; 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>

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_poynt_terminal_user poynt.terminal.user model_poynt_terminal base.group_user 1 0 0 0
3 access_poynt_terminal_admin poynt.terminal.admin model_poynt_terminal base.group_system 1 1 1 1
4 access_poynt_payment_wizard_user poynt.payment.wizard.user model_poynt_payment_wizard account.group_account_invoice 1 1 1 0
5 access_poynt_payment_wizard_admin poynt.payment.wizard.admin model_poynt_payment_wizard base.group_system 1 1 1 1
6 access_poynt_refund_wizard_user poynt.refund.wizard.user model_poynt_refund_wizard account.group_account_invoice 1 1 1 0
7 access_poynt_refund_wizard_admin poynt.refund.wizard.admin model_poynt_refund_wizard base.group_system 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View 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);

View 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>

View File

@@ -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',
},

View 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>

View File

@@ -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">

View 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>

View File

@@ -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>

View 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>

View 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

View 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,
},
}

View 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>

View 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',
}

View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()