From 14fe9ab71619ffe04adaeb3db3b5ff98f1518893 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 25 Feb 2026 23:33:23 -0500 Subject: [PATCH] feat: hide authorizer for rental orders, auto-set sale type Rental orders no longer show the "Authorizer Required?" question or the Authorizer field. The sale type is automatically set to 'Rentals' when creating or confirming a rental order. Validation logic also skips authorizer checks for rental sale type. Made-with: Cursor --- .../controllers/portal_main.py | 52 +- fusion_claims/models/sale_order.py | 23 +- fusion_claims/models/task_sync.py | 33 +- fusion_claims/models/technician_task.py | 197 +++- fusion_claims/views/sale_order_views.xml | 2 +- fusion_claims/views/technician_task_views.xml | 15 +- .../wizard/ready_for_delivery_wizard.py | 87 +- fusion_poynt/__manifest__.py | 1 + fusion_poynt/models/payment_provider.py | 47 + fusion_poynt/models/payment_token.py | 9 +- fusion_poynt/models/payment_transaction.py | 133 ++- fusion_poynt/models/poynt_terminal.py | 22 +- fusion_poynt/security/ir.model.access.csv | 15 +- fusion_poynt/security/security.xml | 49 + fusion_poynt/utils.py | 62 +- fusion_poynt/views/account_move_views.xml | 8 +- fusion_poynt/views/poynt_terminal_views.xml | 3 +- fusion_poynt/views/sale_order_views.xml | 1 + fusion_poynt/wizard/poynt_payment_wizard.py | 32 +- .../wizard/poynt_payment_wizard_views.xml | 4 +- fusion_poynt/wizard/poynt_refund_wizard.py | 19 +- .../wizard/poynt_refund_wizard_views.xml | 3 +- fusion_rental/__manifest__.py | 11 +- fusion_rental/controllers/main.py | 401 ++++++-- fusion_rental/data/ir_cron_data.xml | 23 +- fusion_rental/data/mail_template_data.xml | 874 +++++++++++++++++- fusion_rental/models/cancellation_request.py | 5 +- fusion_rental/models/renewal_log.py | 3 +- fusion_rental/models/res_config_settings.py | 40 + fusion_rental/models/sale_order.py | 873 ++++++++++++++--- .../report/report_rental_agreement.xml | 489 +++++----- fusion_rental/security/ir.model.access.csv | 4 + fusion_rental/security/security.xml | 4 +- .../static/src/css/inspection_photos.css | 22 + .../static/src/js/inspection_photo_field.js | 43 + .../static/src/xml/inspection_photo_field.xml | 44 + fusion_rental/views/menus.xml | 2 +- .../views/portal_sale_rental_override.xml | 76 ++ .../views/res_config_settings_views.xml | 58 +- fusion_rental/views/sale_order_views.xml | 245 +++-- fusion_rental/wizard/__init__.py | 1 + .../wizard/deposit_deduction_wizard.py | 403 +++++++- .../wizard/deposit_deduction_wizard_views.xml | 62 +- fusion_rental/wizard/manual_renewal_wizard.py | 116 ++- .../wizard/manual_renewal_wizard_views.xml | 33 +- fusion_rental/wizard/rental_return_wizard.py | 230 +++++ .../wizard/rental_return_wizard_views.xml | 74 ++ fusion_whitelabels/__init__.py | 1 + fusion_whitelabels/__manifest__.py | 30 + .../static/src/css/whitelabel.css | 3 + .../views/fusion_whitelabels_templates.xml | 27 + 51 files changed, 4192 insertions(+), 822 deletions(-) create mode 100644 fusion_poynt/security/security.xml create mode 100644 fusion_rental/static/src/css/inspection_photos.css create mode 100644 fusion_rental/static/src/js/inspection_photo_field.js create mode 100644 fusion_rental/static/src/xml/inspection_photo_field.xml create mode 100644 fusion_rental/views/portal_sale_rental_override.xml create mode 100644 fusion_rental/wizard/rental_return_wizard.py create mode 100644 fusion_rental/wizard/rental_return_wizard_views.xml create mode 100644 fusion_whitelabels/__init__.py create mode 100644 fusion_whitelabels/__manifest__.py create mode 100644 fusion_whitelabels/static/src/css/whitelabel.css create mode 100644 fusion_whitelabels/views/fusion_whitelabels_templates.xml diff --git a/fusion_authorizer_portal/controllers/portal_main.py b/fusion_authorizer_portal/controllers/portal_main.py index eeef7c0..8b60fec 100644 --- a/fusion_authorizer_portal/controllers/portal_main.py +++ b/fusion_authorizer_portal/controllers/portal_main.py @@ -1111,9 +1111,11 @@ class AuthorizerPortal(CustomerPortal): SaleOrder = request.env['sale.order'].sudo() today = fields.Date.context_today(request.env['fusion.technician.task']) - # Today's tasks + # Today's tasks (lead or additional technician) today_tasks = Task.search([ + '|', ('technician_id', '=', user.id), + ('additional_technician_ids', 'in', [user.id]), ('scheduled_date', '=', today), ('status', '!=', 'cancelled'), ], order='sequence, time_start, id') @@ -1143,7 +1145,9 @@ class AuthorizerPortal(CustomerPortal): from datetime import timedelta tomorrow = today + timedelta(days=1) tomorrow_count = Task.search_count([ + '|', ('technician_id', '=', user.id), + ('additional_technician_ids', 'in', [user.id]), ('scheduled_date', '=', tomorrow), ('status', '!=', 'cancelled'), ]) @@ -1181,7 +1185,7 @@ class AuthorizerPortal(CustomerPortal): user = request.env.user Task = request.env['fusion.technician.task'].sudo() - domain = [('technician_id', '=', user.id)] + domain = ['|', ('technician_id', '=', user.id), ('additional_technician_ids', 'in', [user.id])] if filter_status == 'scheduled': domain.append(('status', '=', 'scheduled')) @@ -1237,14 +1241,19 @@ class AuthorizerPortal(CustomerPortal): try: task = Task.browse(task_id) - if not task.exists() or task.technician_id.id != user.id: + if not task.exists() or ( + task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids + ): raise AccessError(_('You do not have access to this task.')) except (AccessError, MissingError): return request.redirect('/my/technician/tasks') # Check for earlier uncompleted tasks (sequential enforcement) earlier_incomplete = Task.search([ + '|', ('technician_id', '=', user.id), + ('additional_technician_ids', 'in', [user.id]), ('scheduled_date', '=', task.scheduled_date), ('time_start', '<', task.time_start), ('status', 'not in', ['completed', 'cancelled']), @@ -1284,7 +1293,10 @@ class AuthorizerPortal(CustomerPortal): Attachment = request.env['ir.attachment'].sudo() try: task = Task.browse(task_id) - if not task.exists() or task.technician_id.id != user.id: + if not task.exists() or ( + task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids + ): return {'success': False, 'error': 'Task not found'} from markupsafe import Markup, escape @@ -1421,7 +1433,10 @@ class AuthorizerPortal(CustomerPortal): try: task = Task.browse(task_id) - if not task.exists() or task.technician_id.id != user.id: + if not task.exists() or ( + task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids + ): return {'success': False, 'error': 'Task not found or not assigned to you'} if action == 'en_route': @@ -1470,7 +1485,10 @@ class AuthorizerPortal(CustomerPortal): ICP = request.env['ir.config_parameter'].sudo() task = Task.browse(task_id) - if not task.exists() or task.technician_id.id != user.id: + if not task.exists() or ( + task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids + ): return {'success': False, 'error': 'Task not found'} api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '') @@ -1532,7 +1550,10 @@ class AuthorizerPortal(CustomerPortal): ICP = request.env['ir.config_parameter'].sudo() task = Task.browse(task_id) - if not task.exists() or task.technician_id.id != user.id: + if not task.exists() or ( + task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids + ): return {'success': False, 'error': 'Task not found'} api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '') @@ -1589,7 +1610,10 @@ class AuthorizerPortal(CustomerPortal): ICP = request.env['ir.config_parameter'].sudo() task = Task.browse(task_id) - if not task.exists() or task.technician_id.id != user.id: + if not task.exists() or ( + task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids + ): return {'success': False, 'error': 'Task not found'} api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '') @@ -1672,7 +1696,9 @@ class AuthorizerPortal(CustomerPortal): tomorrow = today + timedelta(days=1) tomorrow_tasks = Task.search([ + '|', ('technician_id', '=', user.id), + ('additional_technician_ids', 'in', [user.id]), ('scheduled_date', '=', tomorrow), ('status', '!=', 'cancelled'), ], order='sequence, time_start, id') @@ -1711,7 +1737,9 @@ class AuthorizerPortal(CustomerPortal): return request.redirect('/my/technician') tasks = Task.search([ + '|', ('technician_id', '=', user.id), + ('additional_technician_ids', 'in', [user.id]), ('scheduled_date', '=', schedule_date), ('status', '!=', 'cancelled'), ], order='sequence, time_start, id') @@ -1835,7 +1863,9 @@ class AuthorizerPortal(CustomerPortal): if not has_access and partner.is_technician_portal: task_count = request.env['fusion.technician.task'].sudo().search_count([ ('sale_order_id', '=', order.id), + '|', ('technician_id', '=', user.id), + ('additional_technician_ids', 'in', [user.id]), ]) if task_count: has_access = True @@ -2490,7 +2520,8 @@ class AuthorizerPortal(CustomerPortal): if ( not task.exists() - or task.technician_id.id != user.id + or (task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids) or task.task_type != 'pickup' ): return request.redirect('/my') @@ -2515,7 +2546,8 @@ class AuthorizerPortal(CustomerPortal): if ( not task.exists() - or task.technician_id.id != user.id + or (task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids) or task.task_type != 'pickup' ): return {'success': False, 'error': 'Access denied.'} diff --git a/fusion_claims/models/sale_order.py b/fusion_claims/models/sale_order.py index df6eeb2..045f85e 100644 --- a/fusion_claims/models/sale_order.py +++ b/fusion_claims/models/sale_order.py @@ -479,14 +479,14 @@ class SaleOrder(models.Model): ) # Authorizer Required field - only for certain sale types - # For: odsp, direct_private, insurance, other, rental + # For: odsp, direct_private, insurance, other (NOT rental) x_fc_authorizer_required = fields.Selection( selection=[ ('yes', 'Yes'), ('no', 'No'), ], string='Authorizer Required?', - help='For ODSP, Direct/Private, Insurance, Others, and Rentals - specify if an authorizer is needed.', + help='For ODSP, Direct/Private, Insurance, Others - specify if an authorizer is needed.', ) # Computed field to determine if authorizer should be shown @@ -498,21 +498,18 @@ class SaleOrder(models.Model): @api.depends('x_fc_sale_type', 'x_fc_authorizer_required') def _compute_show_authorizer(self): """Compute whether to show the authorizer field based on sale type and authorizer_required.""" - # Sale types that require the "Authorizer Required?" question - optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental') - # Sale types where authorizer is always shown/required + optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other') always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy') for order in self: sale_type = order.x_fc_sale_type - if sale_type in always_auth_types: - # Always show authorizer for ADP-related types + if sale_type == 'rental': + order.x_fc_show_authorizer = False + elif sale_type in always_auth_types: order.x_fc_show_authorizer = True elif sale_type in optional_auth_types: - # Show authorizer only if user selected "Yes" order.x_fc_show_authorizer = order.x_fc_authorizer_required == 'yes' else: - # No sale type selected - don't show order.x_fc_show_authorizer = False # Computed field to determine if "Authorizer Required?" question should be shown @@ -524,7 +521,7 @@ class SaleOrder(models.Model): @api.depends('x_fc_sale_type') def _compute_show_authorizer_question(self): """Compute whether to show the 'Authorizer Required?' field.""" - optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental') + optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other') for order in self: order.x_fc_show_authorizer_question = order.x_fc_sale_type in optional_auth_types @@ -5810,19 +5807,19 @@ class SaleOrder(models.Model): if should_validate_authorizer: always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy') - optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental') + optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other') for order in self: sale_type = get_val(order, 'x_fc_sale_type') + if sale_type == 'rental': + continue auth_id = get_val(order, 'x_fc_authorizer_id') auth_required = get_val(order, 'x_fc_authorizer_required') if sale_type in always_auth_types: - # Always required for these types if not auth_id: raise UserError("Authorizer is required for this sale type.") elif sale_type in optional_auth_types and auth_required == 'yes': - # Required only if user selected "Yes" if not auth_id: raise UserError("Authorizer is required. You selected 'Yes' for Authorizer Required.") diff --git a/fusion_claims/models/task_sync.py b/fusion_claims/models/task_sync.py index 4a16592..e985119 100644 --- a/fusion_claims/models/task_sync.py +++ b/fusion_claims/models/task_sync.py @@ -26,7 +26,8 @@ from datetime import timedelta _logger = logging.getLogger(__name__) SYNC_TASK_FIELDS = [ - 'x_fc_sync_uuid', 'name', 'technician_id', 'task_type', 'status', + 'x_fc_sync_uuid', 'name', 'technician_id', 'additional_technician_ids', + 'task_type', 'status', 'scheduled_date', 'time_start', 'time_end', 'duration_hours', 'address_street', 'address_street2', 'address_city', 'address_zip', 'address_lat', 'address_lng', 'priority', 'partner_id', @@ -196,7 +197,11 @@ class FusionTaskSyncConfig(models.Model): _logger.exception("Task sync push to %s failed", config.name) def _push_tasks_to_remote(self, tasks, operation, local_instance_id): - """Push task data to a single remote instance.""" + """Push task data to a single remote instance. + + Maps additional_technician_ids via sync IDs so the remote instance + also blocks those technicians' schedules. + """ self.ensure_one() local_map = self._get_local_tech_map() remote_map = self._get_remote_tech_map() @@ -213,12 +218,22 @@ class FusionTaskSyncConfig(models.Model): if not remote_tech_uid: continue + # Map additional technicians to remote user IDs + remote_additional_ids = [] + for tech in task.additional_technician_ids: + add_sync_id = local_map.get(tech.id) + if add_sync_id: + remote_add_uid = remote_map.get(add_sync_id) + if remote_add_uid: + remote_additional_ids.append(remote_add_uid) + task_data = { 'x_fc_sync_uuid': task.x_fc_sync_uuid, 'x_fc_sync_source': local_instance_id, 'x_fc_sync_remote_id': task.id, 'name': f"[{local_instance_id.upper()}] {task.name}", 'technician_id': remote_tech_uid, + 'additional_technician_ids': [(6, 0, remote_additional_ids)], 'task_type': task.task_type, 'status': task.status, 'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False, @@ -295,7 +310,9 @@ class FusionTaskSyncConfig(models.Model): remote_tasks = self._rpc( 'fusion.technician.task', 'search_read', [[ + '|', ('technician_id', 'in', remote_tech_ids), + ('additional_technician_ids', 'in', remote_tech_ids), ('scheduled_date', '>=', str(cutoff)), ('x_fc_sync_source', '=', False), ]], @@ -324,12 +341,24 @@ class FusionTaskSyncConfig(models.Model): partner_raw = rt.get('partner_id') client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else '' + # Map additional technicians from remote to local + local_additional_ids = [] + remote_add_raw = rt.get('additional_technician_ids', []) + if remote_add_raw and isinstance(remote_add_raw, list): + for add_uid in remote_add_raw: + add_sync_id = remote_syncid_by_uid.get(add_uid) + if add_sync_id: + local_add_uid = local_syncid_to_uid.get(add_sync_id) + if local_add_uid: + local_additional_ids.append(local_add_uid) + vals = { 'x_fc_sync_uuid': sync_uuid, 'x_fc_sync_source': self.instance_id, 'x_fc_sync_remote_id': rt['id'], 'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}", 'technician_id': local_uid, + 'additional_technician_ids': [(6, 0, local_additional_ids)], 'task_type': rt.get('task_type', 'delivery'), 'status': rt.get('status', 'scheduled'), 'scheduled_date': rt.get('scheduled_date'), diff --git a/fusion_claims/models/technician_task.py b/fusion_claims/models/technician_task.py index 9d4504e..20a263b 100644 --- a/fusion_claims/models/technician_task.py +++ b/fusion_claims/models/technician_task.py @@ -30,7 +30,7 @@ class FusionTechnicianTask(models.Model): _rec_name = 'name' def _compute_display_name(self): - """Richer display name: Client - Type | 9:00 AM - 10:00 AM.""" + """Richer display name: Client - Type | 9:00 AM - 10:00 AM [+2 techs].""" type_labels = dict(self._fields['task_type'].selection) for task in self: client = task.x_fc_sync_client_name if task.x_fc_sync_source else (task.partner_id.name or '') @@ -41,6 +41,9 @@ class FusionTechnicianTask(models.Model): label = ' - '.join(p for p in parts if p) if start and end: label += f' | {start} - {end}' + extra = len(task.additional_technician_ids) + if extra: + label += f' [+{extra} tech{"s" if extra > 1 else ""}]' task.display_name = label or task.name # ------------------------------------------------------------------ @@ -111,13 +114,45 @@ class FusionTechnicianTask(models.Model): required=True, tracking=True, domain="[('x_fc_is_field_staff', '=', True)]", - help='Shows: users marked as Field Staff (technicians and sales reps)', + help='Lead technician responsible for this task', ) technician_name = fields.Char( related='technician_id.name', string='Technician Name', store=True, ) + additional_technician_ids = fields.Many2many( + 'res.users', + 'technician_task_additional_tech_rel', + 'task_id', + 'user_id', + string='Additional Technicians', + domain="[('x_fc_is_field_staff', '=', True)]", + tracking=True, + help='Additional technicians assigned to assist on this task', + ) + all_technician_ids = fields.Many2many( + 'res.users', + compute='_compute_all_technician_ids', + string='All Technicians', + help='Lead + additional technicians combined', + ) + additional_tech_count = fields.Integer( + compute='_compute_all_technician_ids', + string='Extra Techs', + ) + all_technician_names = fields.Char( + compute='_compute_all_technician_ids', + string='All Technician Names', + ) + + @api.depends('technician_id', 'additional_technician_ids') + def _compute_all_technician_ids(self): + for task in self: + all_techs = task.technician_id | task.additional_technician_ids + task.all_technician_ids = all_techs + task.additional_tech_count = len(task.additional_technician_ids) + task.all_technician_names = ', '.join(all_techs.mapped('name')) sale_order_id = fields.Many2one( 'sale.order', @@ -444,7 +479,9 @@ class FusionTechnicianTask(models.Model): return (preferred_start, preferred_start + duration) domain = [ + '|', ('technician_id', '=', tech_id), + ('additional_technician_ids', 'in', [tech_id]), ('scheduled_date', '=', date), ('status', 'not in', ['cancelled']), ] @@ -535,6 +572,7 @@ class FusionTechnicianTask(models.Model): """Return a list of available (start, end) gaps for a technician on a date. Used by schedule_info_html to show green "available" badges. + Considers tasks where the tech is either lead or additional. """ STORE_OPEN, STORE_CLOSE = self._get_store_hours() @@ -542,7 +580,9 @@ class FusionTechnicianTask(models.Model): return [(STORE_OPEN, STORE_CLOSE)] domain = [ + '|', ('technician_id', '=', tech_id), + ('additional_technician_ids', 'in', [tech_id]), ('scheduled_date', '=', date), ('status', 'not in', ['cancelled']), ] @@ -673,9 +713,11 @@ class FusionTechnicianTask(models.Model): continue exclude_id = task.id if task.id else 0 - # Find other tasks for the same technician+date + # Find other tasks for the same technician+date (lead or additional) others = self.sudo().search([ + '|', ('technician_id', '=', task.technician_id.id), + ('additional_technician_ids', 'in', [task.technician_id.id]), ('scheduled_date', '=', task.scheduled_date), ('status', 'not in', ['cancelled']), ('id', '!=', exclude_id), @@ -749,9 +791,11 @@ class FusionTechnicianTask(models.Model): continue exclude_id = task.id if task.id else 0 - # Find the task that ends just before this one starts + # Find the task that ends just before this one starts (lead or additional) prev_tasks = self.sudo().search([ + '|', ('technician_id', '=', task.technician_id.id), + ('additional_technician_ids', 'in', [task.technician_id.id]), ('scheduled_date', '=', task.scheduled_date), ('status', 'not in', ['cancelled']), ('id', '!=', exclude_id), @@ -1003,9 +1047,13 @@ class FusionTechnicianTask(models.Model): )) - @api.constrains('technician_id', 'scheduled_date', 'time_start', 'time_end') + @api.constrains('technician_id', 'additional_technician_ids', + 'scheduled_date', 'time_start', 'time_end') def _check_no_overlap(self): - """Prevent overlapping bookings for the same technician on the same date.""" + """Prevent overlapping bookings for the same technician on the same date. + + Checks both the lead technician and all additional technicians. + """ for task in self: if task.status == 'cancelled': continue @@ -1032,29 +1080,38 @@ class FusionTechnicianTask(models.Model): current_hour = now.hour + now.minute / 60.0 if task.time_start < current_hour: pass # Allow editing existing tasks that started earlier today - # Check overlap with other tasks - overlapping = self.sudo().search([ - ('technician_id', '=', task.technician_id.id), - ('scheduled_date', '=', task.scheduled_date), - ('status', 'not in', ['cancelled']), - ('id', '!=', task.id), - ('time_start', '<', task.time_end), - ('time_end', '>', task.time_start), - ], limit=1) - if overlapping: - start_str = self._float_to_time_str(overlapping.time_start) - end_str = self._float_to_time_str(overlapping.time_end) - raise ValidationError(_( - "Time slot overlaps with %(task)s (%(start)s - %(end)s). " - "Please choose a different time.", - task=overlapping.name, - start=start_str, - end=end_str, - )) + # Check overlap for lead + additional technicians + all_tech_ids = (task.technician_id | task.additional_technician_ids).ids + for tech_id in all_tech_ids: + tech_name = self.env['res.users'].browse(tech_id).name + overlapping = self.sudo().search([ + '|', + ('technician_id', '=', tech_id), + ('additional_technician_ids', 'in', [tech_id]), + ('scheduled_date', '=', task.scheduled_date), + ('status', 'not in', ['cancelled']), + ('id', '!=', task.id), + ('time_start', '<', task.time_end), + ('time_end', '>', task.time_start), + ], limit=1) + if overlapping: + start_str = self._float_to_time_str(overlapping.time_start) + end_str = self._float_to_time_str(overlapping.time_end) + raise ValidationError(_( + "%(tech)s has a time conflict with %(task)s " + "(%(start)s - %(end)s). Please choose a different time.", + tech=tech_name, + task=overlapping.name, + start=start_str, + end=end_str, + )) - # Check travel time gap to the NEXT task on the same day + # Check travel time gaps for lead technician only + # (additional techs travel with the lead, same destination) next_task = self.sudo().search([ + '|', ('technician_id', '=', task.technician_id.id), + ('additional_technician_ids', 'in', [task.technician_id.id]), ('scheduled_date', '=', task.scheduled_date), ('status', 'not in', ['cancelled']), ('id', '!=', task.id), @@ -1082,9 +1139,10 @@ class FusionTechnicianTask(models.Model): travel=travel_min, )) - # Check travel time gap FROM the PREVIOUS task on the same day prev_task = self.sudo().search([ + '|', ('technician_id', '=', task.technician_id.id), + ('additional_technician_ids', 'in', [task.technician_id.id]), ('scheduled_date', '=', task.scheduled_date), ('status', 'not in', ['cancelled']), ('id', '!=', task.id), @@ -1151,8 +1209,11 @@ class FusionTechnicianTask(models.Model): exclude_id = self._origin.id if self._origin else 0 duration = max(self.duration_hours or 1.0, 0.25) + all_tech_ids = (self.technician_id | self.additional_technician_ids).ids overlapping = self.sudo().search([ - ('technician_id', '=', self.technician_id.id), + '|', + ('technician_id', 'in', all_tech_ids), + ('additional_technician_ids', 'in', all_tech_ids), ('scheduled_date', '=', self.scheduled_date), ('status', 'not in', ['cancelled']), ('id', '!=', exclude_id), @@ -1347,14 +1408,22 @@ class FusionTechnicianTask(models.Model): # Capture old tech+date combos BEFORE write for travel recalc travel_fields = {'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng', - 'scheduled_date', 'sequence', 'time_start', 'technician_id'} + 'scheduled_date', 'sequence', 'time_start', 'technician_id', + 'additional_technician_ids'} needs_travel_recalc = travel_fields & set(vals.keys()) old_combos = set() if needs_travel_recalc: - old_combos = {(t.technician_id.id, t.scheduled_date) for t in self} + for t in self: + old_combos.add((t.technician_id.id, t.scheduled_date)) + for tech in t.additional_technician_ids: + old_combos.add((tech.id, t.scheduled_date)) res = super().write(vals) if needs_travel_recalc: - new_combos = {(t.technician_id.id, t.scheduled_date) for t in self} + new_combos = set() + for t in self: + new_combos.add((t.technician_id.id, t.scheduled_date)) + for tech in t.additional_technician_ids: + new_combos.add((tech.id, t.scheduled_date)) all_combos = old_combos | new_combos self._recalculate_combos_travel(all_combos) @@ -1374,7 +1443,8 @@ class FusionTechnicianTask(models.Model): old_end=old['time_end'], ) # Push updates to remote instances for local tasks - sync_fields = {'technician_id', 'scheduled_date', 'time_start', 'time_end', + sync_fields = {'technician_id', 'additional_technician_ids', + 'scheduled_date', 'time_start', 'time_end', 'duration_hours', 'status', 'task_type', 'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng', 'partner_id'} @@ -1412,7 +1482,7 @@ class FusionTechnicianTask(models.Model): f'
' f' Technician Task Scheduled
' f'{self.name} ({task_type_label}) - {date_str} at {time_str}
' - f'Technician: {self.technician_id.name}
' + f'Technician(s): {self.all_technician_names or self.technician_id.name}
' f'View Task' f'
' ) @@ -1441,10 +1511,11 @@ class FusionTechnicianTask(models.Model): previous_status = order.x_fc_adp_application_status # Update the sale order status and delivery fields + all_tech_ids = (task.technician_id | task.additional_technician_ids).ids order.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': 'ready_delivery', 'x_fc_status_before_delivery': previous_status, - 'x_fc_delivery_technician_ids': [(4, task.technician_id.id)], + 'x_fc_delivery_technician_ids': [(4, tid) for tid in all_tech_ids], 'x_fc_ready_for_delivery_date': fields.Datetime.now(), 'x_fc_scheduled_delivery_datetime': task.datetime_start, }) @@ -1469,7 +1540,7 @@ class FusionTechnicianTask(models.Model): f'
Ready for Delivery{early_badge}
' f'' @@ -1485,7 +1556,7 @@ class FusionTechnicianTask(models.Model): # Send email notifications try: order._send_ready_for_delivery_email( - technicians=task.technician_id, + technicians=task.technician_id | task.additional_technician_ids, scheduled_datetime=task.datetime_start, notes=task.description, ) @@ -1493,8 +1564,18 @@ class FusionTechnicianTask(models.Model): _logger.warning("Ready for delivery email failed for %s: %s", order.name, e) def _recalculate_day_travel_chains(self): - """Recalculate travel for all tech+date combos affected by these tasks.""" - combos = {(t.technician_id.id, t.scheduled_date) for t in self if t.technician_id and t.scheduled_date} + """Recalculate travel for all tech+date combos affected by these tasks. + + Includes combos for additional technicians so their schedules update too. + """ + combos = set() + for t in self: + if not t.scheduled_date: + continue + if t.technician_id: + combos.add((t.technician_id.id, t.scheduled_date)) + for tech in t.additional_technician_ids: + combos.add((tech.id, t.scheduled_date)) self._recalculate_combos_travel(combos) def _get_technician_start_address(self, tech_id): @@ -1543,7 +1624,9 @@ class FusionTechnicianTask(models.Model): if not tech_id or not date: continue all_day_tasks = self.sudo().search([ + '|', ('technician_id', '=', tech_id), + ('additional_technician_ids', 'in', [tech_id]), ('scheduled_date', '=', date), ('status', 'not in', ['cancelled']), ], order='time_start, sequence, id') @@ -1574,10 +1657,15 @@ class FusionTechnicianTask(models.Model): # ------------------------------------------------------------------ def _check_previous_tasks_completed(self): - """Check that all earlier tasks for the same technician+date are completed.""" + """Check that all earlier tasks for the same technician+date are completed. + + Considers tasks where the technician is either lead or additional. + """ self.ensure_one() earlier_incomplete = self.sudo().search([ + '|', ('technician_id', '=', self.technician_id.id), + ('additional_technician_ids', 'in', [self.technician_id.id]), ('scheduled_date', '=', self.scheduled_date), ('time_start', '<', self.time_start), ('status', 'not in', ['completed', 'cancelled']), @@ -1709,6 +1797,13 @@ class FusionTechnicianTask(models.Model): ), user_id=order.user_id.id or self.env.uid, ) + try: + order._send_damage_notification_email() + except Exception as e: + _logger.error( + "Failed to send damage notification for %s: %s", + order.name, e, + ) def action_cancel_task(self): """Cancel the task. Sends cancellation email and reverts sale order if delivery.""" @@ -1842,7 +1937,7 @@ class FusionTechnicianTask(models.Model): f'
Technician Task Completed
' f'' f'
' @@ -1859,7 +1954,7 @@ class FusionTechnicianTask(models.Model): """Send an Odoo notification to whoever created/scheduled the task.""" self.ensure_one() # Notify the task creator (scheduler) if they're not the technician - if self.create_uid and self.create_uid != self.technician_id: + if self.create_uid and self.create_uid not in self.all_technician_ids: task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type) task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form' client_name = self.partner_id.name or 'N/A' @@ -1889,8 +1984,8 @@ class FusionTechnicianTask(models.Model): f'{case_ref or "N/A"}' f'Task:' f'{self.name}' - f'Technician:' - f'{self.technician_id.name}' + f'Technician(s):' + f'{self.all_technician_names or self.technician_id.name}' f'Location:' f'{address_str}' f'' @@ -1927,7 +2022,7 @@ class FusionTechnicianTask(models.Model): end_str = self._float_to_time_str(self.time_end) rows.append(('Scheduled', f'{date_str}, {start_str} - {end_str}')) if self.technician_id: - rows.append(('Technician', self.technician_id.name)) + rows.append(('Technician', self.all_technician_names or self.technician_id.name)) if self.address_display: rows.append(('Address', self.address_display)) return rows @@ -1943,9 +2038,10 @@ class FusionTechnicianTask(models.Model): if self.partner_id and self.partner_id.email: to_emails.append(self.partner_id.email) - # Technician email - if self.technician_id and self.technician_id.email: - cc_emails.append(self.technician_id.email) + # Technician emails (lead + additional) + for tech in (self.technician_id | self.additional_technician_ids): + if tech.email: + cc_emails.append(tech.email) # Sales rep from the sale order if self.sale_order_id and self.sale_order_id.user_id and \ @@ -2161,10 +2257,15 @@ class FusionTechnicianTask(models.Model): return False def get_next_task_for_technician(self): - """Get the next task in sequence for the same technician+date after this one.""" + """Get the next task in sequence for the same technician+date after this one. + + Considers tasks where the technician is either lead or additional. + """ self.ensure_one() return self.sudo().search([ + '|', ('technician_id', '=', self.technician_id.id), + ('additional_technician_ids', 'in', [self.technician_id.id]), ('scheduled_date', '=', self.scheduled_date), ('time_start', '>=', self.time_start), ('status', 'in', ['scheduled', 'en_route']), diff --git a/fusion_claims/views/sale_order_views.xml b/fusion_claims/views/sale_order_views.xml index 1f07835..194f28a 100644 --- a/fusion_claims/views/sale_order_views.xml +++ b/fusion_claims/views/sale_order_views.xml @@ -27,7 +27,7 @@ - + diff --git a/fusion_claims/views/technician_task_views.xml b/fusion_claims/views/technician_task_views.xml index 8e1228b..b2261e6 100644 --- a/fusion_claims/views/technician_task_views.xml +++ b/fusion_claims/views/technician_task_views.xml @@ -56,7 +56,8 @@ - + @@ -162,6 +163,10 @@ + + @@ -305,6 +312,8 @@ + + @@ -345,6 +354,10 @@ min +
+ + + technician(s) +
diff --git a/fusion_claims/wizard/ready_for_delivery_wizard.py b/fusion_claims/wizard/ready_for_delivery_wizard.py index ffc9650..fec65a6 100644 --- a/fusion_claims/wizard/ready_for_delivery_wizard.py +++ b/fusion_claims/wizard/ready_for_delivery_wizard.py @@ -156,7 +156,10 @@ class ReadyForDeliveryWizard(models.TransientModel): return {'type': 'ir.actions.act_window_close'} def _create_technician_tasks(self, order): - """Create a technician task for each assigned technician. + """Create a single delivery task with lead + additional technicians. + + The first selected technician becomes the lead. Any remaining + technicians are assigned as additional technicians on the same task. The task model's create() method auto-populates address fields from the linked sale order's shipping address when address_street @@ -164,7 +167,7 @@ class ReadyForDeliveryWizard(models.TransientModel): """ Task = self.env['fusion.technician.task'] scheduled_date = False - time_start = 9.0 # default 9:00 AM + time_start = 9.0 if self.scheduled_datetime: scheduled_date = self.scheduled_datetime.date() @@ -172,43 +175,45 @@ class ReadyForDeliveryWizard(models.TransientModel): else: scheduled_date = fields.Date.context_today(self) - created_tasks = self.env['fusion.technician.task'] - for tech in self.technician_ids: - vals = { - 'technician_id': tech.id, - 'sale_order_id': order.id, - 'task_type': 'delivery', - 'scheduled_date': scheduled_date, - 'time_start': time_start, - 'time_end': time_start + 1.0, # 1 hour default - 'partner_id': order.partner_id.id, - 'description': self.notes or '', - 'pod_required': True, - } - task = Task.create(vals) - created_tasks |= task - _logger.info("Created delivery task %s for %s on order %s", task.name, tech.name, order.name) + techs = self.technician_ids + lead_tech = techs[0] + additional_techs = techs[1:] if len(techs) > 1 else self.env['res.users'] - # Post a summary of created tasks back to the sale order chatter - if created_tasks: - task_lines = '' - for t in created_tasks: - task_url = f'/web#id={t.id}&model=fusion.technician.task&view_type=form' - time_str = t.time_start_12h or '' - task_lines += ( - f'
  • {t.name} - ' - f'{t.technician_id.name} ' - f'({t.scheduled_date.strftime("%b %d, %Y") if t.scheduled_date else "TBD"}' - f'{" at " + time_str if time_str else ""})
  • ' - ) - summary = Markup( - '
    ' - ' Delivery Tasks Created' - '
      %s
    ' - '
    ' - ) % Markup(task_lines) - order.message_post( - body=summary, - message_type='notification', - subtype_xmlid='mail.mt_note', - ) + vals = { + 'technician_id': lead_tech.id, + 'additional_technician_ids': [(6, 0, additional_techs.ids)] if additional_techs else False, + 'sale_order_id': order.id, + 'task_type': 'delivery', + 'scheduled_date': scheduled_date, + 'time_start': time_start, + 'time_end': time_start + 1.0, + 'partner_id': order.partner_id.id, + 'description': self.notes or '', + 'pod_required': True, + } + task = Task.create(vals) + _logger.info( + "Created delivery task %s for %s (+%d additional) on order %s", + task.name, lead_tech.name, len(additional_techs), order.name, + ) + + task_url = f'/web#id={task.id}&model=fusion.technician.task&view_type=form' + time_str = task.time_start_12h or '' + all_names = ', '.join(techs.mapped('name')) + task_line = ( + f'
  • {task.name} - ' + f'{all_names} ' + f'({task.scheduled_date.strftime("%b %d, %Y") if task.scheduled_date else "TBD"}' + f'{" at " + time_str if time_str else ""})
  • ' + ) + summary = Markup( + '
    ' + ' Delivery Task Created' + '
      %s
    ' + '
    ' + ) % Markup(task_line) + order.message_post( + body=summary, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) diff --git a/fusion_poynt/__manifest__.py b/fusion_poynt/__manifest__.py index 28ba207..41adc7d 100644 --- a/fusion_poynt/__manifest__.py +++ b/fusion_poynt/__manifest__.py @@ -9,6 +9,7 @@ 'description': " ", 'depends': ['payment', 'account_payment', 'sale'], 'data': [ + 'security/security.xml', 'security/ir.model.access.csv', 'report/poynt_receipt_report.xml', diff --git a/fusion_poynt/models/payment_provider.py b/fusion_poynt/models/payment_provider.py index 143b1ee..f3d2d8c 100644 --- a/fusion_poynt/models/payment_provider.py +++ b/fusion_poynt/models/payment_provider.py @@ -253,6 +253,53 @@ class PaymentProvider(models.Model): return result + # === BUSINESS METHODS - TOKENIZE / CHARGE === # + + def _poynt_tokenize_nonce(self, nonce): + """Exchange a Poynt Collect nonce for a long-lived payment token JWT. + + :param str nonce: The one-time nonce from Poynt Collect JS. + :return: The tokenize response containing card details, cardId, + paymentToken (JWT), and AVS/CVV verification results. + :rtype: dict + :raises ValidationError: If the tokenize call fails. + """ + self.ensure_one() + return self._poynt_make_request( + 'POST', + 'cards/tokenize', + payload={'nonce': nonce}, + ) + + def _poynt_charge_token(self, payment_jwt, amount, currency, + action='SALE', reference=''): + """Charge a stored payment token JWT via the tokenize/charge endpoint. + + :param str payment_jwt: The payment token JWT from _poynt_tokenize_nonce. + :param float amount: The charge amount in major currency units. + :param recordset currency: The currency record. + :param str action: SALE or AUTHORIZE (default SALE). + :param str reference: Optional reference note for the transaction. + :return: The transaction result dict from Poynt. + :rtype: dict + :raises ValidationError: If the charge fails. + """ + self.ensure_one() + payload = poynt_utils.build_token_charge_payload( + action=action, + amount=amount, + currency=currency, + payment_jwt=payment_jwt, + business_id=self.poynt_business_id, + store_id=self.poynt_store_id or '', + reference=reference, + ) + return self._poynt_make_request( + 'POST', + 'cards/tokenize/charge', + payload=payload, + ) + # === BUSINESS METHODS - INLINE FORM === # def _poynt_get_inline_form_values(self, amount, currency, partner_id, is_validation, diff --git a/fusion_poynt/models/payment_token.py b/fusion_poynt/models/payment_token.py index f863769..31c909d 100644 --- a/fusion_poynt/models/payment_token.py +++ b/fusion_poynt/models/payment_token.py @@ -16,6 +16,13 @@ class PaymentToken(models.Model): help="The unique card identifier stored on the Poynt platform.", readonly=True, ) + poynt_payment_token = fields.Char( + string="Poynt Payment Token (JWT)", + help="Long-lived JWT issued by Poynt /cards/tokenize, used for " + "recurring charges via /cards/tokenize/charge.", + readonly=True, + groups='base.group_system', + ) def _poynt_validate_stored_card(self): """Validate that the stored card is still usable on Poynt. @@ -35,7 +42,7 @@ class PaymentToken(models.Model): ) try: - result = self.provider_id._poynt_make_request( + result = self.provider_id.sudo()._poynt_make_request( 'GET', f'cards/{self.poynt_card_id}', ) diff --git a/fusion_poynt/models/payment_transaction.py b/fusion_poynt/models/payment_transaction.py index 6762204..a4d7c32 100644 --- a/fusion_poynt/models/payment_transaction.py +++ b/fusion_poynt/models/payment_transaction.py @@ -48,6 +48,9 @@ class PaymentTransaction(models.Model): copy=False, ) + def _get_provider_sudo(self): + return self.provider_id.sudo() + # === BUSINESS METHODS - PAYMENT FLOW === # def _get_specific_processing_values(self, processing_values): @@ -64,7 +67,8 @@ class PaymentTransaction(models.Model): poynt_data = self._poynt_create_order_and_authorize() - base_url = self.provider_id.get_base_url() + provider = self._get_provider_sudo() + base_url = provider.get_base_url() return_url = url_join( base_url, f'{PoyntController._return_url}?{url_encode({"reference": self.reference})}', @@ -74,8 +78,8 @@ class PaymentTransaction(models.Model): 'poynt_order_id': poynt_data.get('order_id', ''), 'poynt_transaction_id': poynt_data.get('transaction_id', ''), 'return_url': return_url, - 'business_id': self.provider_id.poynt_business_id, - 'is_test': self.provider_id.state == 'test', + 'business_id': provider.poynt_business_id, + 'is_test': provider.state == 'test', } def _send_payment_request(self): @@ -104,26 +108,29 @@ class PaymentTransaction(models.Model): :rtype: dict """ try: + provider = self._get_provider_sudo() 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 '', + business_id=provider.poynt_business_id, + store_id=provider.poynt_store_id or '', ) - order_result = self.provider_id._poynt_make_request( + order_result = provider._poynt_make_request( 'POST', 'orders', payload=order_payload, ) order_id = order_result.get('id', '') self.poynt_order_id = order_id - action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE' + action = 'AUTHORIZE' if provider.capture_manually else 'SALE' txn_payload = poynt_utils.build_transaction_payload( action=action, amount=self.amount, currency=self.currency_id, order_id=order_id, reference=self.reference, + business_id=provider.poynt_business_id, + store_id=provider.poynt_store_id or '', ) - txn_result = self.provider_id._poynt_make_request( + txn_result = provider._poynt_make_request( 'POST', 'transactions', payload=txn_payload, ) @@ -144,46 +151,68 @@ class PaymentTransaction(models.Model): def _poynt_process_token_payment(self): """Process a payment using a stored token (card on file). - For token-based payments we send a SALE or AUTHORIZE using the - stored card ID from the payment token. + Uses the JWT payment token via POST /cards/tokenize/charge when + available. Falls back to the legacy cardId flow for tokens that + were created before the JWT migration. """ try: - action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE' + provider = self._get_provider_sudo() + action = 'AUTHORIZE' if provider.capture_manually else 'SALE' + payment_jwt = self.token_id.poynt_payment_token - funding_source = { - 'type': 'CREDIT_DEBIT', - 'card': { - 'cardId': self.token_id.poynt_card_id, - }, - } - - 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, - ) - order_id = order_result.get('id', '') - self.poynt_order_id = order_id - - txn_payload = poynt_utils.build_transaction_payload( - action=action, - amount=self.amount, - currency=self.currency_id, - order_id=order_id, - reference=self.reference, - funding_source=funding_source, - ) - txn_result = self.provider_id._poynt_make_request( - 'POST', 'transactions', payload=txn_payload, - ) + if payment_jwt: + txn_result = provider._poynt_charge_token( + payment_jwt=payment_jwt, + amount=self.amount, + currency=self.currency_id, + action=action, + reference=self.reference, + ) + else: + funding_source = { + 'type': 'CREDIT_DEBIT', + 'card': { + 'cardId': self.token_id.poynt_card_id, + }, + 'entryDetails': { + 'customerPresenceStatus': 'MOTO', + 'entryMode': 'KEYED', + }, + } + order_payload = poynt_utils.build_order_payload( + self.reference, self.amount, self.currency_id, + business_id=provider.poynt_business_id, + store_id=provider.poynt_store_id or '', + ) + order_result = provider._poynt_make_request( + 'POST', 'orders', payload=order_payload, + ) + order_id = order_result.get('id', '') + self.poynt_order_id = order_id + txn_payload = poynt_utils.build_transaction_payload( + action=action, + amount=self.amount, + currency=self.currency_id, + order_id=order_id, + reference=self.reference, + funding_source=funding_source, + business_id=provider.poynt_business_id, + store_id=provider.poynt_store_id or '', + ) + txn_result = provider._poynt_make_request( + 'POST', 'transactions', payload=txn_payload, + ) transaction_id = txn_result.get('id', '') self.poynt_transaction_id = transaction_id self.provider_reference = transaction_id + order_id = txn_result.get('orderIdFromTransaction', '') or \ + txn_result.get('orderId', '') or \ + getattr(self, 'poynt_order_id', '') or '' + if order_id: + self.poynt_order_id = order_id + payment_data = { 'reference': self.reference, 'poynt_order_id': order_id, @@ -211,8 +240,9 @@ class PaymentTransaction(models.Model): parent_txn_id = source_tx.poynt_transaction_id or source_tx.provider_reference + provider = self._get_provider_sudo() try: - txn_data = self.provider_id._poynt_make_request( + txn_data = provider._poynt_make_request( 'GET', f'transactions/{parent_txn_id}', ) for link in txn_data.get('links', []): @@ -248,7 +278,7 @@ class PaymentTransaction(models.Model): 'notes': f'Refund for {source_tx.reference}', } - result = self.provider_id._poynt_make_request( + result = provider._poynt_make_request( 'POST', 'transactions', payload=refund_payload, ) @@ -287,7 +317,7 @@ class PaymentTransaction(models.Model): }, } - result = self.provider_id._poynt_make_request( + result = self._get_provider_sudo()._poynt_make_request( 'POST', 'transactions', payload=capture_payload, ) @@ -313,7 +343,7 @@ class PaymentTransaction(models.Model): txn_id = source_tx.provider_reference or source_tx.poynt_transaction_id try: - result = self.provider_id._poynt_make_request( + result = self._get_provider_sudo()._poynt_make_request( 'POST', f'transactions/{txn_id}/void', ) @@ -580,17 +610,18 @@ class PaymentTransaction(models.Model): return super()._create_payment(**extra_create_values) self.ensure_one() + provider = self._get_provider_sudo() 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_method_line = provider.journal_id.inbound_payment_method_line_ids\ + .filtered(lambda l: l.payment_provider_id == provider) 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, + 'journal_id': provider.journal_id.id, + 'company_id': provider.company_id.id, 'payment_method_line_id': payment_method_line.id, 'payment_token_id': self.token_id.id, 'payment_transaction_id': self.id, @@ -608,7 +639,7 @@ class PaymentTransaction(models.Model): payment = self.env['account.payment'].create(payment_values) - bank_account = self.provider_id.journal_id.default_account_id + bank_account = provider.journal_id.default_account_id if bank_account and bank_account.account_type == 'asset_cash': payment.outstanding_account_id = bank_account @@ -675,7 +706,7 @@ class PaymentTransaction(models.Model): fields in :attr:`poynt_receipt_data` as a JSON blob.""" txn_data = {} try: - txn_data = self.provider_id._poynt_make_request( + txn_data = self._get_provider_sudo()._poynt_make_request( 'GET', f'transactions/{self.poynt_transaction_id}', ) except (ValidationError, Exception): @@ -757,7 +788,7 @@ class PaymentTransaction(models.Model): if not invoice: return - receipt_content = self.provider_id._poynt_fetch_receipt( + receipt_content = self._get_provider_sudo()._poynt_fetch_receipt( self.poynt_transaction_id, ) if not receipt_content: diff --git a/fusion_poynt/models/poynt_terminal.py b/fusion_poynt/models/poynt_terminal.py index de291e4..db17ce3 100644 --- a/fusion_poynt/models/poynt_terminal.py +++ b/fusion_poynt/models/poynt_terminal.py @@ -66,17 +66,21 @@ class PoyntTerminal(models.Model): # === BUSINESS METHODS === # + def _get_provider_sudo(self): + return self.provider_id.sudo() + def action_refresh_status(self): """Refresh the terminal status from Poynt Cloud.""" for terminal in self: try: - store_id = terminal.store_id_poynt or terminal.provider_id.poynt_store_id + provider = terminal._get_provider_sudo() + store_id = terminal.store_id_poynt or provider.poynt_store_id if store_id: endpoint = f'stores/{store_id}/storeDevices/{terminal.device_id}' else: endpoint = f'storeDevices/{terminal.device_id}' - result = terminal.provider_id._poynt_make_request('GET', endpoint) + result = provider._poynt_make_request('GET', endpoint) poynt_status = result.get('status', 'UNKNOWN') if poynt_status == 'ACTIVATED': @@ -130,7 +134,8 @@ class PoyntTerminal(models.Model): if order_id: payment_request['orderId'] = order_id - store_id = self.store_id_poynt or self.provider_id.poynt_store_id or '' + provider = self._get_provider_sudo() + store_id = self.store_id_poynt or provider.poynt_store_id or '' data_str = json.dumps({ 'action': 'sale', @@ -142,12 +147,12 @@ class PoyntTerminal(models.Model): }) try: - result = self.provider_id._poynt_make_request( + result = provider._poynt_make_request( 'POST', 'cloudMessages', business_scoped=False, payload={ - 'businessId': self.provider_id.poynt_business_id, + 'businessId': provider.poynt_business_id, 'storeId': store_id, 'deviceId': self.device_id, 'ttl': 300, @@ -173,7 +178,7 @@ class PoyntTerminal(models.Model): :return: The full callback URL. :rtype: str """ - base_url = self.provider_id.get_base_url() + base_url = self._get_provider_sudo().get_base_url() return f"{base_url}/payment/poynt/terminal/callback" def action_check_terminal_payment_status(self, reference): @@ -188,8 +193,9 @@ class PoyntTerminal(models.Model): """ self.ensure_one() + provider = self._get_provider_sudo() try: - txn_result = self.provider_id._poynt_make_request( + txn_result = provider._poynt_make_request( 'GET', 'transactions', params={ @@ -201,7 +207,7 @@ class PoyntTerminal(models.Model): transactions = txn_result.get('transactions', []) if not transactions: - txn_result = self.provider_id._poynt_make_request( + txn_result = provider._poynt_make_request( 'GET', 'transactions', params={ diff --git a/fusion_poynt/security/ir.model.access.csv b/fusion_poynt/security/ir.model.access.csv index f8d5929..52de204 100644 --- a/fusion_poynt/security/ir.model.access.csv +++ b/fusion_poynt/security/ir.model.access.csv @@ -1,7 +1,10 @@ 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 +access_poynt_terminal_user,poynt.terminal.user,model_poynt_terminal,group_fusion_poynt_user,1,0,0,0 +access_poynt_terminal_admin,poynt.terminal.admin,model_poynt_terminal,group_fusion_poynt_admin,1,1,1,1 +access_poynt_payment_wizard_user,poynt.payment.wizard.user,model_poynt_payment_wizard,group_fusion_poynt_user,1,1,1,0 +access_poynt_payment_wizard_admin,poynt.payment.wizard.admin,model_poynt_payment_wizard,group_fusion_poynt_admin,1,1,1,1 +access_poynt_refund_wizard_user,poynt.refund.wizard.user,model_poynt_refund_wizard,group_fusion_poynt_user,1,1,1,0 +access_poynt_refund_wizard_admin,poynt.refund.wizard.admin,model_poynt_refund_wizard,group_fusion_poynt_admin,1,1,1,1 +access_payment_provider_poynt_user,payment.provider.poynt.user,payment.model_payment_provider,group_fusion_poynt_user,1,0,0,0 +access_payment_transaction_poynt_user,payment.transaction.poynt.user,payment.model_payment_transaction,group_fusion_poynt_user,1,1,1,0 +access_payment_method_poynt_user,payment.method.poynt.user,payment.model_payment_method,group_fusion_poynt_user,1,0,0,0 diff --git a/fusion_poynt/security/security.xml b/fusion_poynt/security/security.xml new file mode 100644 index 0000000..5397443 --- /dev/null +++ b/fusion_poynt/security/security.xml @@ -0,0 +1,49 @@ + + + + + + + + + Fusion Poynt + 47 + + + + + + + + + Fusion Poynt + 47 + + + + + + + + + + User + 10 + + + + + + + + + + + Administrator + 20 + + + + + + diff --git a/fusion_poynt/utils.py b/fusion_poynt/utils.py index c7c7317..9c65e1b 100644 --- a/fusion_poynt/utils.py +++ b/fusion_poynt/utils.py @@ -239,7 +239,8 @@ def build_order_payload(reference, amount, currency, business_id='', def build_transaction_payload( - action, amount, currency, order_id=None, reference='', funding_source=None + action, amount, currency, order_id=None, reference='', + funding_source=None, business_id='', store_id='', ): """Build a Poynt transaction payload for charge/auth/capture. @@ -249,11 +250,23 @@ def build_transaction_payload( :param str order_id: The Poynt order UUID (optional). :param str reference: The Odoo transaction reference. :param dict funding_source: The funding source / card data (optional). + :param str business_id: The Poynt business UUID (optional). + :param str store_id: The Poynt store UUID (optional). :return: The Poynt-formatted transaction payload. :rtype: dict """ minor_amount = format_poynt_amount(amount, currency) + context = { + 'source': 'WEB', + 'sourceApp': 'odoo.fusion_poynt', + 'transactionInstruction': 'ONLINE_AUTH_REQUIRED', + } + if business_id: + context['businessId'] = business_id + if store_id: + context['storeId'] = store_id + payload = { 'action': action, 'amounts': { @@ -263,11 +276,7 @@ def build_transaction_payload( 'cashbackAmount': 0, 'currency': currency.name, }, - 'context': { - 'source': 'WEB', - 'sourceApp': 'odoo.fusion_poynt', - 'transactionInstruction': 'ONLINE_AUTH_REQUIRED', - }, + 'context': context, 'notes': reference, } @@ -281,3 +290,44 @@ def build_transaction_payload( payload['fundingSource'] = funding_source return payload + + +def build_token_charge_payload( + action, amount, currency, payment_jwt, + business_id='', store_id='', reference='', +): + """Build a payload for POST /cards/tokenize/charge. + + :param str action: SALE or AUTHORIZE. + :param float amount: Amount in major currency units. + :param recordset currency: Currency record. + :param str payment_jwt: The payment token JWT from /cards/tokenize. + :param str business_id: Poynt business UUID. + :param str store_id: Poynt store UUID. + :param str reference: Optional reference note. + :return: The charge payload dict. + :rtype: dict + """ + minor_amount = format_poynt_amount(amount, currency) + + context = {} + if business_id: + context['businessId'] = business_id + if store_id: + context['storeId'] = store_id + + payload = { + 'action': action, + 'context': context, + 'amounts': { + 'transactionAmount': minor_amount, + 'orderAmount': minor_amount, + 'currency': currency.name, + }, + 'fundingSource': { + 'cardToken': payment_jwt, + }, + } + if reference: + payload['notes'] = reference + return payload diff --git a/fusion_poynt/views/account_move_views.xml b/fusion_poynt/views/account_move_views.xml index 3c6e0b1..95f22af 100644 --- a/fusion_poynt/views/account_move_views.xml +++ b/fusion_poynt/views/account_move_views.xml @@ -27,7 +27,7 @@ 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" + groups="fusion_poynt.group_fusion_poynt_user" data-hotkey="p"/> @@ -39,7 +39,7 @@ 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" + groups="fusion_poynt.group_fusion_poynt_user" data-hotkey="r"/> @@ -51,7 +51,7 @@ class="btn-secondary" icon="fa-envelope" invisible="state != 'posted' or move_type != 'out_invoice' or not has_poynt_receipt" - groups="account.group_account_invoice"/> + groups="fusion_poynt.group_fusion_poynt_user"/> @@ -62,7 +62,7 @@ class="btn-secondary" icon="fa-envelope" invisible="state != 'posted' or move_type != 'out_refund' or not poynt_refunded" - groups="account.group_account_invoice"/> + groups="fusion_poynt.group_fusion_poynt_user"/> diff --git a/fusion_poynt/views/poynt_terminal_views.xml b/fusion_poynt/views/poynt_terminal_views.xml index f093c70..8483a51 100644 --- a/fusion_poynt/views/poynt_terminal_views.xml +++ b/fusion_poynt/views/poynt_terminal_views.xml @@ -105,6 +105,7 @@ name="Poynt Terminals" parent="account.root_payment_menu" action="action_poynt_terminal" - sequence="15"/> + sequence="15" + groups="fusion_poynt.group_fusion_poynt_user"/> diff --git a/fusion_poynt/views/sale_order_views.xml b/fusion_poynt/views/sale_order_views.xml index b8ceb00..f72b160 100644 --- a/fusion_poynt/views/sale_order_views.xml +++ b/fusion_poynt/views/sale_order_views.xml @@ -14,6 +14,7 @@ class="btn-secondary" icon="fa-credit-card" invisible="state not in ('sale', 'done')" + groups="fusion_poynt.group_fusion_poynt_user" data-hotkey="p"/>
    diff --git a/fusion_poynt/wizard/poynt_payment_wizard.py b/fusion_poynt/wizard/poynt_payment_wizard.py index 5a5a716..cf23181 100644 --- a/fusion_poynt/wizard/poynt_payment_wizard.py +++ b/fusion_poynt/wizard/poynt_payment_wizard.py @@ -42,6 +42,11 @@ class PoyntPaymentWizard(models.TransientModel): required=True, domain="[('code', '=', 'poynt'), ('state', '!=', 'disabled')]", ) + provider_name = fields.Char( + related='provider_id.name', + string="Poynt Provider", + readonly=True, + ) payment_mode = fields.Selection( selection=[ @@ -109,7 +114,7 @@ class PoyntPaymentWizard(models.TransientModel): res['amount'] = invoice.amount_residual res['currency_id'] = invoice.currency_id.id - provider = self.env['payment.provider'].search([ + provider = self.env['payment.provider'].sudo().search([ ('code', '=', 'poynt'), ('state', '!=', 'disabled'), ], limit=1) @@ -120,10 +125,15 @@ class PoyntPaymentWizard(models.TransientModel): return res + def _get_provider_sudo(self): + return self.provider_id.sudo() + @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 + if self.provider_id: + provider = self._get_provider_sudo() + if provider.poynt_default_terminal_id: + self.terminal_id = provider.poynt_default_terminal_id def action_collect_payment(self): """Dispatch to the appropriate payment method.""" @@ -213,7 +223,8 @@ class PoyntPaymentWizard(models.TransientModel): }, } - action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE' + provider = self._get_provider_sudo() + action = 'AUTHORIZE' if provider.capture_manually else 'SALE' minor_amount = poynt_utils.format_poynt_amount( self.amount, self.currency_id, ) @@ -232,7 +243,7 @@ class PoyntPaymentWizard(models.TransientModel): 'source': 'WEB', 'sourceApp': 'odoo.fusion_poynt', 'transactionInstruction': 'ONLINE_AUTH_REQUIRED', - 'businessId': self.provider_id.poynt_business_id, + 'businessId': provider.poynt_business_id, }, 'notes': reference, } @@ -243,7 +254,7 @@ class PoyntPaymentWizard(models.TransientModel): 'type': 'POYNT_ORDER', }] - result = self.provider_id._poynt_make_request( + result = provider._poynt_make_request( 'POST', 'transactions', payload=txn_payload, ) @@ -303,7 +314,7 @@ class PoyntPaymentWizard(models.TransientModel): if not terminal: raise UserError(_("No terminal associated with this payment.")) - provider = self.provider_id + provider = self._get_provider_sudo() try: txn = self._find_terminal_transaction(provider) @@ -517,14 +528,15 @@ class PoyntPaymentWizard(models.TransientModel): def _create_poynt_order(self, reference): """Create a Poynt order via the API.""" + provider = self._get_provider_sudo() 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 '', + business_id=provider.poynt_business_id, + store_id=provider.poynt_store_id or '', ) - return self.provider_id._poynt_make_request( + return provider._poynt_make_request( 'POST', 'orders', payload=order_payload, ) diff --git a/fusion_poynt/wizard/poynt_payment_wizard_views.xml b/fusion_poynt/wizard/poynt_payment_wizard_views.xml index d71c303..dd9cdf1 100644 --- a/fusion_poynt/wizard/poynt_payment_wizard_views.xml +++ b/fusion_poynt/wizard/poynt_payment_wizard_views.xml @@ -8,6 +8,7 @@
    + +
    + + {{ object.partner_id.lang }} + + + + + + + + Rental: Invoice + Payment Receipt + + {{ object.company_id.name }} - Invoice & Payment Confirmation {{ object.name }} + {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
    +
    +
    +

    + +

    +

    Invoice & Payment Confirmation

    +

    + Payment has been successfully processed for your rental order . +

    + + + + + + + + + + + + + +
    Payment Receipt
    Order
    Invoice
    Description + Rental Charges, Delivery & Services + Security Deposit (Refundable) + Rental Renewal + Damage Assessment Charges + Rental Payment +
    Amount Paid
    Amount
    +
    +

    Your payment has been received and applied. If you have any questions about this charge, please do not hesitate to contact us.

    +
    + +
    --
    +
    +
    +
    +
    + {{ object.partner_id.lang }} + +
    + + + + + + Rental: Damage Notification + + {{ object.company_id.name }} - Rental Inspection Update {{ object.name }} + {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
    +
    +
    +

    + +

    +

    Rental Inspection Update

    +

    + During the pickup inspection of your rental order , our technician noted a condition that requires review. +

    + + + + +
    Inspection Summary
    Order
    StatusFlagged for Review
    +
    +

    Our team is reviewing the inspection findings and will reach out to you shortly with an update. Your security deposit is on hold pending the outcome of this review.

    --
    @@ -323,6 +496,81 @@ + + + + + Rental: Card Reauthorization Request + + {{ object.company_id.name }} - Update Payment Card {{ object.name }} + {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
    +
    +
    +

    + +

    +

    Update Payment Card

    +

    + We need to update the payment card on file for your rental order . Please click the button below to authorize a new card. +

    + +
    +

    Click the button above to securely provide your new payment card details. Your card information is encrypted and securely stored.

    +
    + +
    --
    +
    +
    +
    +
    + {{ object.partner_id.lang }} + +
    + + + + + + Rental: Card Updated Confirmation + + {{ object.company_id.name }} - Payment Card Updated {{ object.name }} + {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
    +
    +
    +

    + +

    +

    Payment Card Updated

    +

    + The payment card on file for your rental order has been updated successfully. +

    + + + + + +
    Card Details
    Cardholder
    Card
    Order
    +
    +

    This card will be used for future rental charges and renewals. If you did not authorize this change, please contact us immediately.

    +
    + +
    --
    +
    +
    +
    +
    + {{ object.partner_id.lang }} + +
    + @@ -401,13 +649,15 @@ fusion_rental.agreement_signing_page -
    +

    Rental Agreement

    -
    Order: SO001
    +
    Order: SO001

    Customer: Name

    Rental Period: Start @@ -415,9 +665,32 @@

    Order Summary
    - + +
    - + + + + + + + @@ -425,7 +698,9 @@ - + + + @@ -433,7 +708,9 @@ - + + + @@ -442,13 +719,25 @@ - + + + - + + + + + + + + + + + @@ -475,42 +764,177 @@ -
    -
    Credit Card Authorization
    -

    Your card will be securely tokenized. We do not store your full card number.

    +
    +
    Credit Card Authorization
    +

    Your card details are securely handled by our PCI-compliant payment partner. We never see your full card number.

    +
    +

    Loading secure card form...

    +
    +
    + +
    +
    Billing Address
    +

    Enter the billing address associated with this card. Used for payment verification.

    -
    -
    -
    -
    -
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + Verify this matches the postal code on your card statement. +
    -
    -
    Signature
    +
    +
    Signature
    -
    +
    -
    +
    - +
    - + +
    +
    +

    If you do not wish to proceed, you may cancel this rental order.

    + +
    + + + + + Rental Agreement Thank You + qweb + fusion_rental.agreement_thank_you_page + + +
    +
    +
    +
    +
    + +

    Agreement Signed Successfully

    +
    +
    +

    Thank you, Customer!

    +

    Your rental agreement for order SO001 has been signed and your payment has been processed.

    + +
    +
    What happens next?
    +
      +
    1. Our team will review your order and prepare the equipment for delivery.
    2. +
    3. We will contact you to schedule a delivery date and time that works for you.
    4. +
    5. On delivery day, our technician will set up the equipment and walk you through its use.
    6. +
    7. You will receive email confirmations at each step.
    8. +
    +
    + +

    Have questions? Reach out to us anytime:

    + + + +
    + + View My Orders + +
    +
    +
    +
    +
    +
    +
    +
    + + + + Card Reauthorization + qweb + fusion_rental.card_reauthorization_page + + +
    +
    +
    +
    +
    +

    Update Payment Card

    +
    +
    +

    + Order: | + Customer: +

    + + +
    +
    +
    + + + + + + + Rental Purchase Interest diff --git a/fusion_rental/models/cancellation_request.py b/fusion_rental/models/cancellation_request.py index 719901b..bae25a8 100644 --- a/fusion_rental/models/cancellation_request.py +++ b/fusion_rental/models/cancellation_request.py @@ -67,7 +67,10 @@ class RentalCancellationRequest(models.Model): def action_confirm(self): """Confirm the cancellation and stop auto-renewal.""" self.ensure_one() - self.order_id.write({'rental_auto_renew': False}) + self.order_id.write({ + 'rental_auto_renew': False, + 'rental_auto_renew_off_reason': f"Customer requested cancellation ({self.reason or 'No reason provided'})", + }) self.write({'state': 'confirmed'}) self._schedule_pickup_activity() self._send_cancellation_confirmation() diff --git a/fusion_rental/models/renewal_log.py b/fusion_rental/models/renewal_log.py index fb6960c..a12933d 100644 --- a/fusion_rental/models/renewal_log.py +++ b/fusion_rental/models/renewal_log.py @@ -1,4 +1,4 @@ -from odoo import fields, models +from odoo import api, fields, models class RentalRenewalLog(models.Model): @@ -67,6 +67,7 @@ class RentalRenewalLog(models.Model): ) notes = fields.Text(string="Notes") + @api.depends('order_id', 'renewal_number') def _compute_display_name(self): for rec in self: rec.display_name = ( diff --git a/fusion_rental/models/res_config_settings.py b/fusion_rental/models/res_config_settings.py index a747b9d..f3b28d3 100644 --- a/fusion_rental/models/res_config_settings.py +++ b/fusion_rental/models/res_config_settings.py @@ -17,3 +17,43 @@ class ResConfigSettings(models.TransientModel): help="Number of days to hold the security deposit after pickup before " "processing the refund. Default is 3 days.", ) + rental_google_maps_api_key = fields.Char( + string="Google Maps API Key", + config_parameter='fusion_rental.google_maps_api_key', + help="API key for Google Places address autocomplete on the rental " + "agreement form. If Fusion Claims is installed, its API key is " + "used automatically and this field can be left blank.", + ) + + rental_marketing_email_pct = fields.Integer( + string="Marketing Email Timing (%)", + config_parameter='fusion_rental.marketing_email_pct', + default=23, + help="Percentage of rental period after which the purchase marketing " + "email is sent. Default 23% (7 days on a 30-day rental). " + "Minimum effective offset is 1 day.", + ) + rental_renewal_reminder_pct = fields.Integer( + string="Renewal Reminder Timing (%)", + config_parameter='fusion_rental.renewal_reminder_pct', + default=10, + help="Percentage of rental period before renewal date at which the " + "reminder is sent. Default 10% (3 days on a 30-day rental). " + "Minimum effective offset is 1 day.", + ) + rental_short_term_threshold_days = fields.Integer( + string="Short-Term Threshold (Days)", + config_parameter='fusion_rental.short_term_threshold_days', + default=3, + help="Rentals shorter than this duration (in days) are treated as " + "short-term. Auto-renewal is delayed until the return window " + "plus grace period has passed.", + ) + rental_short_term_grace_hours = fields.Integer( + string="Short-Term Grace Period (Hours)", + config_parameter='fusion_rental.short_term_grace_hours', + default=1, + help="Hours after the scheduled return time before auto-renewal " + "kicks in for short-term rentals. Gives the customer time to " + "return without being charged.", + ) diff --git a/fusion_rental/models/sale_order.py b/fusion_rental/models/sale_order.py index 60e1fb3..4d3b1a4 100644 --- a/fusion_rental/models/sale_order.py +++ b/fusion_rental/models/sale_order.py @@ -1,3 +1,4 @@ +import base64 import logging import uuid from datetime import timedelta @@ -16,10 +17,15 @@ class SaleOrder(models.Model): # ----------------------------------------------------------------- rental_auto_renew = fields.Boolean( string="Auto-Renew", - default=False, + default=True, tracking=True, help="Automatically renew this rental when the return date is reached.", ) + rental_auto_renew_off_reason = fields.Text( + string="Auto-Renew Disabled Reason", + tracking=True, + help="Reason for disabling automatic renewal on this order.", + ) rental_renewal_count = fields.Integer( string="Renewal Count", default=0, @@ -92,6 +98,25 @@ class SaleOrder(models.Model): copy=False, index=True, ) + rental_billing_address = fields.Char( + string="Billing Address", + copy=False, + help="Billing address provided during agreement signing.", + ) + rental_billing_postal_code = fields.Char( + string="Billing Postal Code", + copy=False, + help="Billing postal/zip code for card verification (AVS).", + ) + rental_agreement_document = fields.Binary( + string="Rental Agreement PDF", + copy=False, + attachment=True, + ) + rental_agreement_document_filename = fields.Char( + string="Agreement Filename", + copy=False, + ) # ----------------------------------------------------------------- # Security deposit fields (v2) @@ -184,6 +209,22 @@ class SaleOrder(models.Model): tracking=True, ) + # ----------------------------------------------------------------- + # Smart-button count fields + # ----------------------------------------------------------------- + rental_deposit_invoice_count = fields.Integer( + compute='_compute_rental_invoice_counts', + ) + rental_charges_invoice_count = fields.Integer( + compute='_compute_rental_invoice_counts', + ) + rental_renewal_invoice_count = fields.Integer( + compute='_compute_rental_invoice_counts', + ) + rental_refund_invoice_count = fields.Integer( + compute='_compute_rental_invoice_counts', + ) + @api.depends('rental_return_date', 'rental_auto_renew') def _compute_rental_next_renewal_date(self): for order in self: @@ -201,6 +242,135 @@ class SaleOrder(models.Model): else: order.rental_original_duration = 0 + @api.depends( + 'rental_deposit_invoice_id', + 'rental_charges_invoice_id', + 'rental_renewal_log_ids.invoice_id', + 'invoice_ids', + ) + def _compute_rental_invoice_counts(self): + for order in self: + order.rental_deposit_invoice_count = 1 if order.rental_deposit_invoice_id else 0 + order.rental_charges_invoice_count = 1 if order.rental_charges_invoice_id else 0 + order.rental_renewal_invoice_count = len( + order.rental_renewal_log_ids.filtered(lambda r: r.invoice_id).mapped('invoice_id') + ) + deposit_inv_id = order.rental_deposit_invoice_id.id if order.rental_deposit_invoice_id else 0 + order.rental_refund_invoice_count = len( + order.invoice_ids.filtered( + lambda inv: inv.move_type == 'out_refund' + and inv.reversed_entry_id.id == deposit_inv_id + ) + ) if deposit_inv_id else 0 + + # ----------------------------------------------------------------- + # Smart-button actions + # ----------------------------------------------------------------- + + def action_view_deposit_invoice(self): + self.ensure_one() + if not self.rental_deposit_invoice_id: + return + return { + 'type': 'ir.actions.act_window', + 'name': _("Security Deposit"), + 'res_model': 'account.move', + 'res_id': self.rental_deposit_invoice_id.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_view_rental_charges_invoice(self): + self.ensure_one() + if not self.rental_charges_invoice_id: + return + return { + 'type': 'ir.actions.act_window', + 'name': _("Rental Charges"), + 'res_model': 'account.move', + 'res_id': self.rental_charges_invoice_id.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_view_renewal_invoices(self): + self.ensure_one() + invoices = self.rental_renewal_log_ids.filtered( + lambda r: r.invoice_id + ).mapped('invoice_id') + if not invoices: + return + if len(invoices) == 1: + return { + 'type': 'ir.actions.act_window', + 'name': _("Renewal Invoice"), + 'res_model': 'account.move', + 'res_id': invoices.id, + 'view_mode': 'form', + 'target': 'current', + } + return { + 'type': 'ir.actions.act_window', + 'name': _("Renewal Invoices"), + 'res_model': 'account.move', + 'view_mode': 'list,form', + 'domain': [('id', 'in', invoices.ids)], + 'target': 'current', + } + + def action_view_refund_invoices(self): + self.ensure_one() + if not self.rental_deposit_invoice_id: + return + refunds = self.invoice_ids.filtered( + lambda inv: inv.move_type == 'out_refund' + and inv.reversed_entry_id == self.rental_deposit_invoice_id + ) + if not refunds: + return + if len(refunds) == 1: + return { + 'type': 'ir.actions.act_window', + 'name': _("Refund"), + 'res_model': 'account.move', + 'res_id': refunds.id, + 'view_mode': 'form', + 'target': 'current', + } + return { + 'type': 'ir.actions.act_window', + 'name': _("Refund Invoices"), + 'res_model': 'account.move', + 'view_mode': 'list,form', + 'domain': [('id', 'in', refunds.ids)], + 'target': 'current', + } + + # ----------------------------------------------------------------- + # Return override -- intercept with inspection wizard + # ----------------------------------------------------------------- + + def action_open_return(self): + """Override to show the inspection wizard before processing returns. + + If inspection has already been completed for this order (e.g. via a + technician task), fall through to the standard return flow. + """ + self.ensure_one() + if self.rental_inspection_status: + return super().action_open_return() + + return { + 'name': _("Return & Inspection"), + 'type': 'ir.actions.act_window', + 'res_model': 'rental.return.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_order_id': self.id, + }, + } + def _has_pending_cancellation(self): """Check if this order has an unresolved cancellation request.""" self.ensure_one() @@ -221,6 +391,46 @@ class SaleOrder(models.Model): return max((self.rental_return_date - self.rental_start_date).days, 1) return 30 + def _get_marketing_target_date(self): + """When to send the purchase marketing email (percentage of period after start).""" + self.ensure_one() + pct = int(self.env['ir.config_parameter'].sudo().get_param( + 'fusion_rental.marketing_email_pct', '23')) + duration = self._get_rental_duration_days() + offset_days = max(round(duration * pct / 100), 1) + start = self.rental_start_date + if not start: + return False + return (start + timedelta(days=offset_days)).date() + + def _get_reminder_target_date(self): + """When to send the renewal reminder (percentage of period before renewal).""" + self.ensure_one() + pct = int(self.env['ir.config_parameter'].sudo().get_param( + 'fusion_rental.renewal_reminder_pct', '10')) + duration = self._get_rental_duration_days() + offset_days = max(round(duration * pct / 100), 1) + if not self.rental_next_renewal_date: + return False + return self.rental_next_renewal_date - timedelta(days=offset_days) + + def _is_short_term_rental(self): + """True if rental duration is below the short-term threshold.""" + self.ensure_one() + threshold = int(self.env['ir.config_parameter'].sudo().get_param( + 'fusion_rental.short_term_threshold_days', '3')) + return self._get_rental_duration_days() < threshold + + def _short_term_grace_expired(self): + """For short-term rentals, True if the return time + grace has passed.""" + self.ensure_one() + grace_hours = int(self.env['ir.config_parameter'].sudo().get_param( + 'fusion_rental.short_term_grace_hours', '1')) + if not self.rental_return_date: + return False + grace_deadline = self.rental_return_date + timedelta(hours=grace_hours) + return fields.Datetime.now() >= grace_deadline + def _get_rental_only_lines(self): """Return order lines that should be invoiced on renewal. @@ -306,6 +516,8 @@ class SaleOrder(models.Model): payment_ok = False if invoice and self.rental_payment_token_id: payment_ok = self._collect_renewal_payment(invoice, renewal_log) + if payment_ok: + self._send_invoice_with_receipt(invoice, 'renewal') if invoice and not self.rental_payment_token_id: self._notify_staff_manual_payment(invoice) @@ -367,7 +579,6 @@ class SaleOrder(models.Model): 'payment_status': 'paid', 'payment_transaction_id': tx.id, }) - self._send_payment_receipt_email(invoice, tx) return True renewal_log.write({ @@ -405,17 +616,33 @@ class SaleOrder(models.Model): ) def _send_renewal_confirmation_email(self, renewal_log, payment_ok): - """Send renewal confirmation email to the customer.""" + """Send renewal confirmation email with invoice + receipt attached.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_renewed', raise_if_not_found=False, ) - if template: - template.with_context( - payment_ok=payment_ok, - renewal_log=renewal_log, - ).send_mail(self.id, force_send=True) + if not template: + return + + attachment_ids = [] + invoice = self.env['account.move'].browse( + renewal_log.invoice_id.id + ) if renewal_log and renewal_log.invoice_id else None + + if invoice and payment_ok: + attachment_ids = self._generate_invoice_attachments( + invoice, 'Renewal', + ) + + template.with_context( + payment_ok=payment_ok, + renewal_log=renewal_log, + ).send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) def _send_payment_receipt_email(self, invoice, transaction): """Send payment receipt email after successful collection.""" @@ -424,11 +651,23 @@ class SaleOrder(models.Model): 'fusion_rental.mail_template_rental_payment_receipt', raise_if_not_found=False, ) - if template: - template.with_context( - invoice=invoice, - transaction=transaction, - ).send_mail(self.id, force_send=True) + if not template: + return + + attachment_ids = [] + if invoice: + attachment_ids = self._generate_invoice_attachments( + invoice, 'Payment Receipt', + ) + + template.with_context( + invoice=invoice, + transaction=transaction, + ).send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) def _send_renewal_reminder_email(self, cancel_token): """Send the 3-day renewal reminder email with cancellation link.""" @@ -479,14 +718,13 @@ class SaleOrder(models.Model): @api.model def _cron_rental_renewal_reminders(self): - """Cron: send reminders 3 days before rental expiry.""" - reminder_date = fields.Date.today() + timedelta(days=3) + """Cron: send renewal reminders based on percentage of rental period.""" + today = fields.Date.today() orders = self.search([ ('is_rental_order', '=', True), ('rental_auto_renew', '=', True), ('rental_status', 'in', ('pickup', 'return')), - ('rental_next_renewal_date', '<=', reminder_date), - ('rental_next_renewal_date', '>', fields.Date.today()), + ('rental_next_renewal_date', '>', today), ('rental_reminder_sent', '=', False), ]) @@ -494,11 +732,17 @@ class SaleOrder(models.Model): if order._has_pending_cancellation(): continue try: + target = order._get_reminder_target_date() + if not target or today < target: + continue cancel_token = order._prepare_renewal_cancellation_token() order._send_renewal_reminder_email(cancel_token) order._send_renewal_reminder_sms(cancel_token) order.write({'rental_reminder_sent': True}) - _logger.info("Sent renewal reminder for %s", order.name) + _logger.info( + "Sent renewal reminder for %s (target date %s)", + order.name, target, + ) except Exception as e: _logger.error("Failed to send reminder for %s: %s", order.name, e) @@ -538,6 +782,26 @@ class SaleOrder(models.Model): order.name, order.rental_renewal_count, order.rental_max_renewals, ) continue + + if order._is_short_term_rental(): + returned = any( + line.qty_returned > 0 + for line in order.order_line + if line.is_rental and not line.display_type + ) + if returned: + _logger.info( + "Skipping auto-renewal for %s: short-term rental already returned.", + order.name, + ) + continue + if not order._short_term_grace_expired(): + _logger.info( + "Skipping auto-renewal for %s: short-term grace period not expired.", + order.name, + ) + continue + try: order._process_auto_renewal() except Exception as e: @@ -562,18 +826,39 @@ class SaleOrder(models.Model): 'default_current_return_date': self.rental_return_date, 'default_new_start_date': new_start, 'default_new_return_date': new_return, + 'default_payment_token_id': self.rental_payment_token_id.id or False, + 'default_previous_start_date': self.rental_start_date, + 'default_previous_return_date': self.rental_return_date, }, } + # ================================================================= + # Auto-set sale type for rental orders + # ================================================================= + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('is_rental_order') and not vals.get('x_fc_sale_type'): + vals['x_fc_sale_type'] = 'rental' + vals.setdefault('x_fc_authorizer_required', 'no') + return super().create(vals_list) + # ================================================================= # Order Confirmation - auto-create deposit invoice # ================================================================= def action_confirm(self): - """Override to create two invoices when a rental order is confirmed: + """Override to create invoices and auto-send agreement on confirmation. + 1. Rental charges + delivery/services (everything except deposit) 2. Security deposit (separate invoice) + 3. Automatically send the rental agreement for signing """ + for order in self: + if order.is_rental_order and not order.x_fc_sale_type: + order.x_fc_sale_type = 'rental' + order.x_fc_authorizer_required = 'no' res = super().action_confirm() for order in self: if not order.is_rental_order: @@ -595,6 +880,14 @@ class SaleOrder(models.Model): "Failed to create deposit invoice for %s: %s", order.name, e, ) + try: + if not order.rental_agreement_signed: + order.action_send_rental_agreement() + except Exception as e: + _logger.error( + "Failed to auto-send rental agreement for %s: %s", + order.name, e, + ) return res # ================================================================= @@ -614,6 +907,83 @@ class SaleOrder(models.Model): template.send_mail(self.id, force_send=True) self.message_post(body=_("Rental agreement sent to customer for signing.")) + def action_send_card_reauthorization(self): + """Send a card reauthorization form link to the customer.""" + self.ensure_one() + self.rental_agreement_token = uuid.uuid4().hex + template = self.env.ref( + 'fusion_rental.mail_template_rental_card_reauthorization', + raise_if_not_found=False, + ) + if template: + template.send_mail(self.id, force_send=True) + self.message_post(body=_( + "Card reauthorization form sent to customer." + )) + + def _process_post_signing_payments(self): + """Auto-collect payments after the customer signs the agreement. + + Posts both invoices, collects payment via token, marks deposit + as collected, emails invoices with receipts, and logs status. + """ + self.ensure_one() + if not self.rental_payment_token_id: + self._notify_staff_manual_payment( + self.rental_charges_invoice_id or self.rental_deposit_invoice_id + ) + return + + charges_inv = self.rental_charges_invoice_id + deposit_inv = self.rental_deposit_invoice_id + + if not charges_inv or not deposit_inv: + try: + if not charges_inv: + self._create_rental_charges_invoice() + charges_inv = self.rental_charges_invoice_id + if not deposit_inv: + deposit_lines = self.order_line.filtered( + lambda l: l.is_security_deposit + ) + if deposit_lines: + self._create_deposit_invoice() + deposit_inv = self.rental_deposit_invoice_id + except Exception as e: + _logger.error( + "Invoice creation during post-signing for %s: %s", + self.name, e, + ) + + for inv in (charges_inv, deposit_inv): + if inv and inv.state == 'cancel': + inv.button_draft() + + if charges_inv and charges_inv.state == 'draft': + charges_inv.action_post() + if charges_inv and charges_inv.state == 'posted' and charges_inv.amount_residual > 0: + ok = self._collect_token_payment_for_invoice(charges_inv) + if ok: + self._send_invoice_with_receipt(charges_inv, 'rental_charges') + else: + self._notify_staff_manual_payment(charges_inv) + + if deposit_inv and deposit_inv.state == 'cancel': + deposit_inv.button_draft() + if deposit_inv and deposit_inv.state == 'draft': + deposit_inv.action_post() + if deposit_inv and deposit_inv.state == 'posted' and deposit_inv.amount_residual > 0: + ok = self._collect_token_payment_for_invoice(deposit_inv) + if ok: + self.rental_deposit_status = 'collected' + self._send_invoice_with_receipt(deposit_inv, 'security_deposit') + else: + self._notify_staff_manual_payment(deposit_inv) + + self.message_post(body=_( + "Agreement signed and payments processed. Order ready for delivery." + )) + def _get_card_last_four(self): """Return the last 4 digits of the stored payment token card.""" self.ensure_one() @@ -641,7 +1011,7 @@ class SaleOrder(models.Model): if not product: raise UserError(_( "Security Deposit product not found. " - "Please upgrade the Fusion Rental Enhancement module." + "Please upgrade the Fusion Rental module." )) return product @@ -716,6 +1086,12 @@ class SaleOrder(models.Model): self.ensure_one() if self.rental_deposit_status != 'collected': return + if not self.rental_inspection_status: + _logger.warning( + "Skipping deposit refund for %s: inspection not completed.", + self.name, + ) + return hold_days = self._get_deposit_hold_days() self.write({ 'rental_deposit_status': 'refund_hold', @@ -727,52 +1103,39 @@ class SaleOrder(models.Model): hold_days, self.rental_deposit_refund_date, )) + self._send_deposit_refund_initiated_email() def _process_deposit_refund(self): - """Process the actual deposit refund (credit note + payment).""" + """Process the actual deposit refund via the deposit wizard, send receipt, and auto-close.""" self.ensure_one() - invoice = self.rental_deposit_invoice_id - if not invoice or invoice.payment_state != 'paid': - return + deposit_total = 0.0 + if self.rental_deposit_invoice_id: + deposit_total = self.rental_deposit_invoice_id.amount_total - credit_note = invoice._reverse_moves( - default_values_list=[{ - 'ref': _("Security deposit refund for %s", self.name), - }], - ) - if credit_note: - credit_note.action_post() - if self.rental_payment_token_id: - self._collect_token_payment_for_invoice(credit_note) + wizard = self.env['deposit.deduction.wizard'].create({ + 'order_id': self.id, + 'deposit_total': deposit_total, + 'action_type': 'full_refund', + }) + wizard.action_confirm() - self.rental_deposit_status = 'refunded' - self._send_deposit_refund_email() - - def _deduct_security_deposit(self, deduction_amount): - """Deduct from security deposit for damage. Refund remainder or invoice extra.""" + def _deduct_security_deposit(self, deduction_amount, reason=''): + """Deduct from security deposit via the deposit wizard.""" self.ensure_one() - invoice = self.rental_deposit_invoice_id - if not invoice: - return + deposit_total = 0.0 + if self.rental_deposit_invoice_id: + deposit_total = self.rental_deposit_invoice_id.amount_total - deposit_total = invoice.amount_total - if deduction_amount >= deposit_total: - self.rental_deposit_status = 'deducted' - overage = deduction_amount - deposit_total - if overage > 0: - self._create_damage_invoice(overage) - else: - refund_amount = deposit_total - deduction_amount - credit_note = invoice._reverse_moves( - default_values_list=[{ - 'ref': _("Partial deposit refund for %s", self.name), - }], - ) - if credit_note: - for line in credit_note.invoice_line_ids: - line.price_unit = refund_amount / max(line.quantity, 1) - credit_note.action_post() - self.rental_deposit_status = 'deducted' + action_type = 'no_refund' if deduction_amount >= deposit_total else 'partial_refund' + + wizard = self.env['deposit.deduction.wizard'].create({ + 'order_id': self.id, + 'deposit_total': deposit_total, + 'action_type': action_type, + 'deduction_amount': deduction_amount, + 'reason': reason or _("Damage deduction"), + }) + wizard.action_confirm() def _create_damage_invoice(self, amount): """Create an additional invoice for damage costs exceeding the deposit.""" @@ -830,6 +1193,35 @@ class SaleOrder(models.Model): self.rental_deposit_status = 'collected' self.message_post(body=_("Security deposit marked as collected.")) + def action_process_deposit(self): + """Open the deposit processing wizard.""" + self.ensure_one() + if not self.rental_inspection_status: + raise UserError(_( + "Return inspection must be completed before processing the " + "security deposit. Please use the 'Return' button to inspect " + "the items first." + )) + if self.rental_deposit_status not in ('collected', 'refund_hold'): + raise UserError(_( + "Deposit must be in 'Collected' or 'Refund Hold' status to process." + )) + deposit_total = 0.0 + if self.rental_deposit_invoice_id: + deposit_total = self.rental_deposit_invoice_id.amount_total + + return { + 'name': _("Process Security Deposit"), + 'type': 'ir.actions.act_window', + 'res_model': 'deposit.deduction.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_order_id': self.id, + 'default_deposit_total': deposit_total, + }, + } + def action_refund_deposit(self): """Button: initiate the security deposit refund hold period.""" self.ensure_one() @@ -839,6 +1231,10 @@ class SaleOrder(models.Model): )) self._refund_security_deposit() + def action_deduct_deposit(self): + """Alias: redirect to the unified deposit processing wizard.""" + return self.action_process_deposit() + def action_force_refund_deposit(self): """Button: skip the hold period and process deposit refund immediately.""" self.ensure_one() @@ -850,29 +1246,6 @@ class SaleOrder(models.Model): self.rental_deposit_refund_date = fields.Date.today() self._process_deposit_refund() - def action_deduct_deposit(self): - """Button: open the deposit deduction wizard.""" - self.ensure_one() - if self.rental_deposit_status != 'collected': - raise UserError(_( - "Deposit must be in 'Collected' status to process a deduction." - )) - deposit_total = 0.0 - if self.rental_deposit_invoice_id: - deposit_total = self.rental_deposit_invoice_id.amount_total - - return { - 'name': _("Deduct Security Deposit"), - 'type': 'ir.actions.act_window', - 'res_model': 'deposit.deduction.wizard', - 'view_mode': 'form', - 'target': 'new', - 'context': { - 'default_order_id': self.id, - 'default_deposit_total': deposit_total, - }, - } - def _collect_token_payment_for_invoice(self, invoice): """Charge the stored payment token for any invoice.""" self.ensure_one() @@ -880,24 +1253,37 @@ class SaleOrder(models.Model): return False try: provider = self.rental_payment_token_id.provider_id - payment_method = self.env['payment.method'].search( - [('code', '=', 'card')], limit=1, + token = self.rental_payment_token_id + PaymentMethod = self.env['payment.method'].sudo().with_context( + active_test=False, ) + payment_method = token.payment_method_id if not payment_method: - payment_method = self.env['payment.method'].search( + payment_method = PaymentMethod.search( + [('code', '=', 'card')], limit=1, + ) + if not payment_method: + payment_method = PaymentMethod.search( [('code', 'in', ('visa', 'mastercard'))], limit=1, ) if not payment_method: + _logger.error("No payment method found for token payment on %s", self.name) return False + reference = self.env['payment.transaction']._compute_reference( + provider.code, + prefix=f"{self.name}-{invoice.name or 'INV'}", + ) + tx = self.env['payment.transaction'].sudo().create({ 'provider_id': provider.id, 'payment_method_id': payment_method.id, 'amount': abs(invoice.amount_residual), 'currency_id': invoice.currency_id.id, 'partner_id': self.partner_id.id, + 'reference': reference, 'operation': 'offline', - 'token_id': self.rental_payment_token_id.id, + 'token_id': token.id, 'invoice_ids': [(4, invoice.id)], }) tx._poynt_process_token_payment() @@ -906,13 +1292,182 @@ class SaleOrder(models.Model): _logger.error("Token payment failed for %s: %s", self.name, e) return False + def _send_deposit_refund_initiated_email(self): + """Send email notifying customer that deposit refund is being processed.""" + self.ensure_one() + template = self.env.ref( + 'fusion_rental.mail_template_rental_deposit_refund_initiated', + raise_if_not_found=False, + ) + if not template: + return + + attachment_ids = [] + credit_note = self._find_deposit_credit_note() + if credit_note: + attachment_ids = self._generate_invoice_attachments( + credit_note, 'Deposit Credit Note', + ) + + template.send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) + def _send_deposit_refund_email(self): - """Send the security deposit refund confirmation email.""" + """Send the security deposit refund completion email with credit note and receipt.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_deposit_refund', raise_if_not_found=False, ) + if not template: + return + + attachment_ids = [] + credit_note = self._find_deposit_credit_note() + if credit_note: + attachment_ids = self._generate_invoice_attachments( + credit_note, 'Deposit Refund', + ) + receipt_ids = self._find_poynt_receipt_attachments(credit_note) + attachment_ids.extend(receipt_ids) + + template.send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) + + def _send_invoice_with_receipt(self, invoice, invoice_type=''): + """Send invoice email with the invoice PDF and payment receipt attached. + + :param invoice: The paid account.move record. + :param invoice_type: Label hint ('rental_charges', 'security_deposit', + 'renewal', 'damage') for context in the email. + """ + self.ensure_one() + template = self.env.ref( + 'fusion_rental.mail_template_rental_invoice_receipt', + raise_if_not_found=False, + ) + if not template: + return + + type_label = { + 'rental_charges': 'Rental Charges', + 'security_deposit': 'Security Deposit', + 'renewal': 'Renewal', + 'damage': 'Damage Assessment', + }.get(invoice_type, 'Invoice') + + attachment_ids = self._generate_invoice_attachments(invoice, type_label) + receipt_ids = self._find_poynt_receipt_attachments(invoice) + attachment_ids.extend(receipt_ids) + + template.with_context( + rental_invoice=invoice, + rental_invoice_type=invoice_type, + ).send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) + + # ================================================================= + # Email Attachment Helpers + # ================================================================= + + def _generate_invoice_attachments(self, invoice, type_label='Invoice'): + """Render an invoice/credit note PDF and return attachment IDs. + + :param invoice: The account.move record. + :param type_label: Human-readable label for the filename. + :returns: List of ir.attachment IDs. + """ + self.ensure_one() + attachment_ids = [] + try: + inv_report = self.env['ir.actions.report']._get_report_from_name( + 'account.report_invoice' + ) + if not inv_report: + inv_report = self.env['ir.actions.report']._get_report_from_name( + 'account.report_invoice_with_payments' + ) + if inv_report: + pdf_content, _ = inv_report._render_qweb_pdf( + inv_report.report_name, [invoice.id] + ) + inv_filename = ( + f"{self.name} - {type_label} - " + f"{invoice.name or 'Draft'}.pdf" + ) + inv_attach = self.env['ir.attachment'].create({ + 'name': inv_filename, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': 'sale.order', + 'res_id': self.id, + 'mimetype': 'application/pdf', + }) + attachment_ids.append(inv_attach.id) + except Exception as e: + _logger.warning( + "Could not generate invoice PDF for %s: %s", + self.name, e, + ) + return attachment_ids + + def _find_poynt_receipt_attachments(self, invoice): + """Find Poynt receipt PDFs attached to an invoice's chatter. + + :param invoice: The account.move record. + :returns: List of ir.attachment IDs. + """ + if not invoice: + return [] + receipts = self.env['ir.attachment'].sudo().search([ + ('res_model', '=', 'account.move'), + ('res_id', '=', invoice.id), + ('name', 'like', 'Payment_Receipt_%'), + ('mimetype', '=', 'application/pdf'), + ], order='id desc', limit=1) + return receipts.ids + + def _find_deposit_credit_note(self): + """Find the credit note for the deposit invoice.""" + self.ensure_one() + if not self.rental_deposit_invoice_id: + return None + credit_note = self.env['account.move'].search([ + ('reversed_entry_id', '=', self.rental_deposit_invoice_id.id), + ('move_type', '=', 'out_refund'), + ('state', '=', 'posted'), + ], order='id desc', limit=1) + return credit_note or None + + def _generate_agreement_attachment_ids(self): + """Return attachment IDs for the signed rental agreement if present.""" + self.ensure_one() + if not self.rental_agreement_document: + return [] + existing = self.env['ir.attachment'].search([ + ('res_model', '=', 'sale.order'), + ('res_id', '=', self.id), + ('name', 'like', '%Rental Agreement%Signed%'), + ('mimetype', '=', 'application/pdf'), + ], order='id desc', limit=1) + return existing.ids + + def _send_damage_notification_email(self): + """Notify customer that technician flagged damage on pickup.""" + self.ensure_one() + template = self.env.ref( + 'fusion_rental.mail_template_rental_damage_notification', + raise_if_not_found=False, + ) if template: template.send_mail(self.id, force_send=True) @@ -975,27 +1530,116 @@ class SaleOrder(models.Model): @api.model def _cron_rental_marketing_emails(self): - """Cron: send day-7 marketing email for purchase conversion.""" - target_date = fields.Date.today() - timedelta(days=7) + """Cron: send purchase marketing email based on percentage of rental period.""" + today = fields.Date.today() orders = self.search([ ('is_rental_order', '=', True), ('state', '=', 'sale'), ('rental_status', 'in', ('pickup', 'return')), ('rental_marketing_email_sent', '=', False), - ('date_order', '>=', fields.Datetime.to_datetime(target_date)), - ('date_order', '<', fields.Datetime.to_datetime( - target_date + timedelta(days=1) - )), + ('rental_start_date', '!=', False), ]) for order in orders: try: + target = order._get_marketing_target_date() + if not target or today < target: + continue order._generate_purchase_coupon() order._send_marketing_email() order.rental_marketing_email_sent = True - _logger.info("Sent marketing email for %s", order.name) + _logger.info("Sent marketing email for %s (target date %s)", order.name, target) except Exception as e: _logger.error("Marketing email failed for %s: %s", order.name, e) + # ================================================================= + # Agreement Document + # ================================================================= + + def _generate_and_attach_signed_agreement(self): + """Generate signed agreement PDF, store it, attach to chatter, and email to customer.""" + self.ensure_one() + report_name = 'fusion_rental.report_rental_agreement' + report = self.env['ir.actions.report']._get_report_from_name(report_name) + if not report: + return + + pdf_content, _report_type = report._render_qweb_pdf(report_name, [self.id]) + + pdf_b64 = base64.b64encode(pdf_content) + partner_name = self.partner_id.name or '' + filename = f"{self.name} - {partner_name} - Rental Agreement - Signed.pdf" + + self.write({ + 'rental_agreement_document': pdf_b64, + 'rental_agreement_document_filename': filename, + }) + + attachment = self.env['ir.attachment'].create({ + 'name': filename, + 'type': 'binary', + 'datas': pdf_b64, + 'res_model': 'sale.order', + 'res_id': self.id, + 'mimetype': 'application/pdf', + }) + self.message_post( + body=_("Signed rental agreement attached."), + attachment_ids=[attachment.id], + ) + + self._send_signed_agreement_email(attachment) + + def _send_signed_agreement_email(self, attachment): + """Email the signed rental agreement PDF to the customer.""" + self.ensure_one() + template = self.env.ref( + 'fusion_rental.mail_template_rental_signed_agreement', + raise_if_not_found=False, + ) + if template: + template.send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': [attachment.id]}, + ) + + def action_preview_rental_agreement(self): + """Open the rental agreement PDF in a preview dialog.""" + self.ensure_one() + if not self.rental_agreement_document: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _("No Document"), + 'message': _("No signed rental agreement document available."), + 'type': 'warning', + 'sticky': False, + }, + } + + attachment = self.env['ir.attachment'].search([ + ('res_model', '=', 'sale.order'), + ('res_id', '=', self.id), + ('name', 'like', '%Rental Agreement%Signed%'), + ], order='id desc', limit=1) + + if not attachment: + attachment = self.env['ir.attachment'].create({ + 'name': self.rental_agreement_document_filename or f"{self.name} - {self.partner_id.name or ''} - Rental Agreement - Signed.pdf", + 'type': 'binary', + 'datas': self.rental_agreement_document, + 'res_model': 'sale.order', + 'res_id': self.id, + 'mimetype': 'application/pdf', + }) + + return { + 'type': 'ir.actions.act_url', + 'url': f'/web/content/{attachment.id}?download=false', + 'target': 'new', + } + # ================================================================= # Transaction Close # ================================================================= @@ -1003,13 +1647,28 @@ class SaleOrder(models.Model): def action_close_rental(self): """Close the rental transaction: delete card token, send thank-you.""" self.ensure_one() + if self.rental_closed: + return + + if not self.rental_inspection_status and self.rental_status in ( + 'return', 'returned', + ): + raise UserError(_( + "Return inspection must be completed before closing the rental. " + "Please use the 'Return' button to inspect the items first." + )) + + for inv in (self.rental_charges_invoice_id, self.rental_deposit_invoice_id): + if inv and inv.amount_residual > 0 and inv.state == 'posted': + _logger.warning( + "Closing rental %s with unpaid invoice %s (residual: %s).", + self.name, inv.name, inv.amount_residual, + ) + if self.rental_payment_token_id: token = self.rental_payment_token_id self.rental_payment_token_id = False - try: - token.unlink() - except Exception: - token.active = False + token.active = False self.rental_closed = True self._send_thank_you_email() @@ -1028,13 +1687,21 @@ class SaleOrder(models.Model): ) def _send_thank_you_email(self): - """Send the thank-you email with Google review link.""" + """Send the thank-you email with Google review link and signed agreement.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_thank_you', raise_if_not_found=False, ) - if template: - template.with_context( - google_review_url=self._get_google_review_url(), - ).send_mail(self.id, force_send=True) + if not template: + return + + attachment_ids = self._generate_agreement_attachment_ids() + + template.with_context( + google_review_url=self._get_google_review_url(), + ).send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) diff --git a/fusion_rental/report/report_rental_agreement.xml b/fusion_rental/report/report_rental_agreement.xml index fd70cea..8cf8043 100644 --- a/fusion_rental/report/report_rental_agreement.xml +++ b/fusion_rental/report/report_rental_agreement.xml @@ -1,6 +1,6 @@ @@ -14,11 +14,11 @@ @@ -66,179 +65,184 @@

    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.

    - -
    - -
    -

    1. Ownership and Condition of Equipment

    -

    The medical equipment is the property of 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. 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.

    -
    - -
    -

    2. Cancellation Policy

    -

    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.

    -
    - -
    -

    3. Security Deposit

    -

    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. is not responsible for delays caused by the Renter's financial institution.

    -
    - -
    -

    4. Liability for Loss or Damage

    -

    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 . The Renter agrees to defend, indemnify, and hold harmless against all claims arising from such loss or damage.

    -
    - -
    -

    5. Risk and Liability

    -

    The Renter assumes all risk and liability for any loss, damage, injury, or death resulting from the use or operation of the medical equipment. is not responsible for any acts or omissions of the Renter or the Renter's agents, servants, or employees.

    -
    - -
    -

    6. Renter Responsibilities

    -

    The Renter is responsible for the full cost of replacement for any damage, loss, theft, or destruction of the medical equipment. 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.

    -
    - -
    -

    7. Indemnification

    -

    The Renter shall indemnify, defend, and hold harmless , 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 's gross negligence or willful misconduct.

    -
    - -
    -

    8. Accident Notification

    -

    The Renter must immediately notify of any accidents, damages, or incidents involving the medical equipment.

    -
    - -
    -

    9. Costs and Expenses

    -

    The Renter agrees to cover all costs, expenses, and attorney's fees incurred by in collecting overdue payments, recovering possession of the equipment, or enforcing claims for damage or loss.

    -
    - -
    -

    10. Independent Status

    -

    The Renter or any driver of the equipment shall not be considered an agent or employee of .

    -
    - -
    -

    11. Binding Obligations

    -

    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.

    -
    - -
    -

    12. Refusal of Service

    -

    reserves the right to refuse rental to any individual or entity at its sole discretion.

    -
    - -
    -

    13. Governing Law

    -

    This Agreement shall be governed by and construed in accordance with the laws of the jurisdiction in which operates.

    -
    - -
    -

    14. Entire Agreement

    -

    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.

    -
    - -
    - - + - -
    - -

    RENTAL DETAILS

    - - -
    DescriptionQtyPrice
    DescriptionQtyPriceTaxTotal
    Product 1
    Service 1
    SECURITY DEPOSIT - REFUNDABLE
    REFUNDABLE UPON RETURN IN GOOD & CLEAN CONDITION
    1
    TotalSubtotal
    Taxes
    Total
    +
    - -
    - - - - - - - - - - - - - - - - - - - - -
    RENTER INFORMATION
    Name
    Address -
    -
    Phone
    Order Ref
    +
    +
    +

    1. Ownership & Condition of Equipment

    +

    All equipment remains the sole property of and is provided in good, sanitized, working condition. The Renter shall not modify, alter, disassemble, relocate, or permit any third party to service or repair the equipment. The Renter shall not sublease, lend, or transfer the equipment to any other person or location without prior written consent from . may repossess equipment without notice if used in violation of this agreement.

    +
    +
    +

    2. Safe Use & Renter Responsibilities

    +

    The Renter shall use equipment strictly in accordance with the manufacturer's instructions, rated weight capacity, and any guidelines provided by at the time of delivery. Equipment must only be used for its intended purpose. The Renter shall not permit use by individuals under 18, persons under the influence of alcohol or narcotics, or in any unsafe manner. The Renter must immediately notify of any malfunction, accident, damage, or incident. Failure to report damage promptly may result in additional liability.

    +
    +
    +

    3. Battery Safety & Maintenance

    +

    For equipment with batteries (power wheelchairs, mobility scooters, patient lifts): (a) The Renter must use ONLY the charger provided by . Use of any third-party charger is strictly prohibited and may result in equipment damage, fire hazard, and full replacement charges. (b) If the provided charger is lost or damaged, the Renter must notify immediately and will be charged for a replacement. (c) Batteries must not be routinely discharged below 30% capacity. (d) Batteries must not be left on the charger for extended, unattended periods beyond a full charge. (e) Equipment must not be used in a manner that causes excessive battery overheating. (f) Upon return, all batteries must be charged to at least 80%. Equipment returned with batteries below 80% will incur a conditioning and service charge. (g) The Renter assumes all risk for battery misuse, including but not limited to overcharging, deep discharge, or use of unauthorized charging equipment.

    +
    +
    +

    4. Cleaning, Sanitation & Return Condition

    +

    The Renter is responsible for keeping all equipment clean and sanitary throughout the rental period. Prior to return, all equipment must be thoroughly cleaned and free of bodily fluids, stains, food residue, pet hair, and debris. Specific requirements by equipment type:

    +

    Mattresses & Cushions: Mattresses must be kept clean and dry at all times. If a mattress is soiled with urine, vomit, blood, or any liquid that penetrates the cover, the mattress is deemed unrecoverable and the Renter will be charged the full replacement cost. Use of a waterproof mattress protector is strongly recommended. Wheelchair seat cushion covers must be washed (cold water, no heat drying) prior to return.

    +

    Wheelchairs & Scooters: All surfaces must be wiped clean. Any liquid spills must be cleaned immediately. Missing parts, accessories, or hardware will result in replacement charges for both the parts and any associated labour.

    +

    Patient Lift Slings: Reusable slings must be washed before return. does not rent hygiene slings. If a reusable sling is used for toileting or incontinence purposes, the Renter must purchase the sling at full cost and it will not be accepted for return.

    +

    If equipment is returned unclean, a cleaning fee will be assessed and deducted from the security deposit or charged to the card on file. If the item is deemed unrecoverable due to contamination, the full replacement cost applies. has sole discretion in determining cleaning and replacement charges.

    +
    - - - - - - - - - - - - - - - - - - - - -
    RENTAL PERIOD
    Start Date - - - - Not specified -
    Return Date - - - - Not specified -
    Duration - - Days - - , Hrs - - - Less than 1 day - Not specified -
    Total Amount
    +
    +
    +

    5. Delivery, Installation & Site Preparation

    +

    Delivery, installation, and pickup fees are one-time, non-refundable charges. The Renter is responsible for ensuring the following before the technician arrives:

    +

    (a) Hospital Beds: The designated room must be cleared of existing furniture to allow adequate space for installation. There must be sufficient clearance for the bed frame, side rails, and technician access on all sides. (b) All Equipment: Walkways and entry paths must be clear of obstacles, ice, snow, and tripping hazards. Pets must be secured in a separate room during delivery and pickup. (c) There must be adequate doorway width and turning space for the equipment to pass through.

    +

    If the technician is unable to complete delivery or pickup due to site conditions not meeting these requirements, the visit will be rescheduled and the Renter will be charged fifty percent (50%) of the original delivery fee for each additional visit.

    +
    +
    +

    6. Pickup & Return

    +

    Upon cancellation or end of rental, will schedule equipment pickup. The Renter must make all equipment and accessories available at the scheduled time. If the Renter denies or is unavailable for a scheduled pickup, a rebooking fee of fifty percent (50%) of the delivery fee will be charged for each subsequent visit. The Renter must ensure there is adequate room and clear pathways for safe removal of the equipment.

    +
    +
    +

    7. Return Inspection

    +

    A technician will inspect equipment on-site at pickup for damage, cleanliness, completeness (including all accessories, chargers, remotes, rails, and hardware), and battery charge level. If the equipment is in good condition, the deposit refund process begins. If damage, missing parts, contamination, or unsanitary conditions are identified, the order is flagged for internal review, the Renter is notified, and the deposit is held until the matter is resolved. Both parts and labour will be charged for any missing or damaged components.

    +
    +
    +

    8. Security Deposit

    +

    A refundable security deposit is collected at the start of the rental. After return and inspection, the deposit is held for a review period (typically three business days). If the equipment is returned in good, clean condition with all accessories, the full deposit is refunded to the original card. Deductions may be applied for: damage, excessive wear, missing parts, cleaning charges, battery conditioning, and replacement costs. If total charges exceed the deposit, the remaining balance is charged to the card on file and an invoice is issued. Deposit refunds take 1 to 3 business days to process; bank posting times may vary. is not responsible for delays by the Renter's financial institution.

    +
    +
    +

    9. Automatic Renewal & Recurring Payments

    +

    This rental renews automatically at the end of each rental period for the same duration and rate unless the Renter cancels. The card on file will be charged automatically on each renewal date. A renewal reminder will be sent prior to the renewal date; the timing is proportional to the rental period (e.g. approximately three days before a 30-day rental, or same-day for a short-term rental). To cancel, the Renter must submit a cancellation request before the renewal date. Cancellation requests received on or after the renewal date will not be processed until the following cycle. For short-term rentals (typically under three days), a grace period applies: the auto-renewal charge is deferred for a brief window after the scheduled return time, allowing the Renter to return the equipment without incurring an additional renewal charge. If the equipment is not returned within the grace period, the renewal is processed automatically.

    +
    +
    +

    10. Cancellation & Early Return

    +

    The Renter may cancel before delivery with a 25% restocking fee. Once delivered, the rental charge for the current period is non-refundable. To stop future renewals, the Renter must cancel at least one (1) day before the next renewal date. Upon cancellation, equipment pickup will be scheduled by .

    +
    + + + + +
    +

    RENTAL AGREEMENT (continued)

    + + + + + + +
    +
    +

    11. Card on File & Payment Authorization

    +

    By signing this agreement, the Renter authorizes to securely store payment card details using industry-standard tokenization. The card on file will be used for: initial rental charges, security deposit collection, automatic renewal payments, damage charges, cleaning fees, missing parts charges, battery conditioning fees, rebooking fees, overage invoices, and deposit refunds. does not store full card numbers. All card data is purged upon rental completion.

    +
    +
    +

    12. Liability, Risk & Medical Disclaimer

    +

    The Renter assumes all risk and liability for any loss, damage, injury, illness, or death arising from the use, storage, or possession of the equipment. The equipment is provided as a mobility or comfort aid only and is NOT a medical device, nor a substitute for professional medical advice, diagnosis, or treatment. makes no representations or warranties regarding the equipment's suitability for any specific medical condition. The Renter confirms they have independently assessed the equipment's suitability for their needs or the needs of the intended user. is not liable for any property left, stored, or transported with the equipment, nor for any indirect, incidental, special, or consequential damages of any kind. The Renter is responsible for the full replacement cost of any equipment that is damaged, lost, stolen, or destroyed.

    +
    +
    +

    13. Indemnification

    +

    The Renter shall indemnify, defend, and hold harmless , its owners, directors, agents, officers, employees, contractors, and technicians from any and all claims, demands, liabilities, damages, injuries, losses, and expenses (including legal fees and court costs) arising from the Renter's use, possession, maintenance, or return of the equipment, or from any third party's use of the equipment while in the Renter's possession, except where caused solely by 's gross negligence.

    +
    +
    +

    14. Purchase Option

    +

    During the rental period, may offer the Renter an option to purchase the rented or equivalent new equipment at a discounted price. If accepted, the purchase is subject to internal approval by . The security deposit is always refunded in full and is never applied toward the purchase price. Delivery of the purchased item and pickup of the rental equipment will be coordinated by .

    +
    +
    +
    +

    15. Communication Consent

    +

    The Renter consents to receive automated communications from related to this rental, including but not limited to: renewal reminders, payment confirmations, invoices, delivery and pickup notifications, inspection results, deposit status updates, and promotional offers, via email and SMS to the contact information on file.

    +
    +
    +

    16. Costs & Expenses

    +

    The Renter agrees to cover all costs, expenses, and legal fees incurred by in collecting overdue payments, recovering equipment, enforcing claims for damage, loss, or contamination, or otherwise enforcing the terms of this agreement.

    +
    +
    +

    17. General Provisions

    +

    The Renter is not an agent or employee of . Any individual signing this agreement on behalf of a corporation, trust, 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. reserves the right to refuse rental to any individual or entity at its sole discretion. shall not be liable for any delays or failures in performance caused by events beyond its reasonable control, including but not limited to natural disasters, pandemics, supplier delays, or government orders. If any provision of this agreement is found to be unenforceable, the remaining provisions shall remain in full force and effect. This Agreement is governed by and construed in accordance with the laws of the Province of Ontario, Canada, and constitutes the entire understanding between the parties, superseding all prior agreements, representations, or understandings, whether written or oral.

    +
    +
    + + + + +
    +

    RENTAL DETAILS

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FIELDDETAILSFIELDDETAILS
    NameOrder Ref
    PhoneStart Date + + - +
    Address +
    +
    Return Date + + - +
    Duration + + Days + + , Hrs + + + Less than 1 day + - +
    + - - - - - + + + + + - -
    DESCRIPTIONQTYUNIT PRICETAXESTOTALDESCRIPTIONQTYUNIT PRICETAXESTOTAL
    @@ -265,8 +269,8 @@
    - - + +
    @@ -283,99 +287,98 @@
    - - + + + +
    CREDIT CARD PAYMENT AUTHORIZATION
    - - + +
    - - + + + + + + + + + - - + + + + + +
    Card #: - - **** **** **** 1234 + Card #: + **** **** **** 1234 +
    Exp Date: + **/** + CVV: *** +
    Cardholder: + + Name - - - - - - - - - - + _________________________________
    Exp Date: - - / - - CVV: - *** - - Security Deposit: + Billing Address: + + Address + , Postal + + + _________________________________ + +
    Rental Charges: + $0.00 + Security Deposit: - $0.00 + $0.00 $___________
    + +
    +

    I authorize to charge the credit card indicated above for: (a) the initial rental charges and security deposit; (b) automatic recurring renewal payments on each renewal date until I cancel; (c) any damage, loss, or overage charges as described in the terms above; and (d) to process refunds to this card. I certify that I am an authorized user of this credit card and will not dispute legitimate charges. By signing this form, I acknowledge that I have read and agree to all terms and conditions of this Rental Agreement, including the automatic renewal, cancellation, security deposit, inspection, and communication provisions.

    +
    +
    + + +
    + - - - - - +
    Cardholder: + +
    FULL NAME (PRINT)
    - Name - - -
    +
    Name
    +
    - Billing Address (if different): -
    +
    +
    SIGNATURE
    + +
    + +
    +
    +
    +
    +
    DATE
    + +
    Date
    +
    +
    - -
    -

    I authorize 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.

    -
    -
    - - -
    -
    - - - - - - -
    -
    FULL NAME (PRINT)
    - -
    Name
    -
    -
    -
    -
    SIGNATURE
    - - - -
    -
    -
    DATE
    - -
    Date
    -
    -
    -
    -
    @@ -392,7 +395,7 @@ qweb-pdf fusion_rental.report_rental_agreement fusion_rental.report_rental_agreement - 'Rental Agreement - %s' % object.name + 'Rental Agreement - %s%s' % (object.name, ' - Signed' if object.rental_agreement_signed else '') report diff --git a/fusion_rental/security/ir.model.access.csv b/fusion_rental/security/ir.model.access.csv index c001973..af84d10 100644 --- a/fusion_rental/security/ir.model.access.csv +++ b/fusion_rental/security/ir.model.access.csv @@ -7,3 +7,7 @@ access_manual_renewal_wizard_user,manual.renewal.wizard.user,model_manual_renewa 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 +access_rental_return_wizard_user,rental.return.wizard.user,model_rental_return_wizard,sales_team.group_sale_salesman,1,1,1,0 +access_rental_return_wizard_manager,rental.return.wizard.manager,model_rental_return_wizard,fusion_rental.group_rental_manager,1,1,1,1 +access_rental_return_wizard_line_user,rental.return.wizard.line.user,model_rental_return_wizard_line,sales_team.group_sale_salesman,1,1,1,0 +access_rental_return_wizard_line_manager,rental.return.wizard.line.manager,model_rental_return_wizard_line,fusion_rental.group_rental_manager,1,1,1,1 diff --git a/fusion_rental/security/security.xml b/fusion_rental/security/security.xml index ab7742e..8b6144a 100644 --- a/fusion_rental/security/security.xml +++ b/fusion_rental/security/security.xml @@ -2,12 +2,12 @@ - Rental Enhancement + Fusion Rental 50 - Rental Enhancement + Fusion Rental 50 diff --git a/fusion_rental/static/src/css/inspection_photos.css b/fusion_rental/static/src/css/inspection_photos.css new file mode 100644 index 0000000..5212b54 --- /dev/null +++ b/fusion_rental/static/src/css/inspection_photos.css @@ -0,0 +1,22 @@ +.o_inspection_photos .o_attachment.o_attachment_many2many { + min-width: 140px; + min-height: 140px; +} + +.o_inspection_photos .o_attachment.o_attachment_many2many .o_image_box { + width: 120px; + height: 120px; +} + +.o_inspection_photos .o_attachment.o_attachment_many2many .o_preview_image { + width: 120px !important; + height: 120px !important; + object-fit: cover; + cursor: pointer; +} + +.o_inspection_photos .o_attachments { + display: flex; + flex-wrap: wrap; + gap: 12px; +} diff --git a/fusion_rental/static/src/js/inspection_photo_field.js b/fusion_rental/static/src/js/inspection_photo_field.js new file mode 100644 index 0000000..f81f3c9 --- /dev/null +++ b/fusion_rental/static/src/js/inspection_photo_field.js @@ -0,0 +1,43 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { Many2ManyBinaryField, many2ManyBinaryField } from "@web/views/fields/many2many_binary/many2many_binary_field"; +import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook"; + +export class InspectionPhotoField extends Many2ManyBinaryField { + setup() { + super.setup(); + this.fileViewer = useFileViewer(); + } + + get viewableFiles() { + return this.files + .filter((f) => this.isImage(f)) + .map((f) => ({ + name: f.name, + isImage: true, + isViewable: true, + downloadUrl: `/web/content/${f.id}?download=true`, + defaultSource: `/web/image/${f.id}`, + })); + } + + onClickImage(fileId) { + const viewable = this.viewableFiles; + const clicked = viewable.find( + (vf) => vf.defaultSource === `/web/image/${fileId}` + ); + if (clicked) { + this.fileViewer.open(clicked, viewable); + } + } +} + +InspectionPhotoField.template = "fusion_rental.InspectionPhotoField"; + +export const inspectionPhotoField = { + ...many2ManyBinaryField, + component: InspectionPhotoField, +}; + +registry.category("fields").add("inspection_photos", inspectionPhotoField); diff --git a/fusion_rental/static/src/xml/inspection_photo_field.xml b/fusion_rental/static/src/xml/inspection_photo_field.xml new file mode 100644 index 0000000..9dbdc5a --- /dev/null +++ b/fusion_rental/static/src/xml/inspection_photo_field.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + +
    +
    + + + + +
    + +
    +
    + +
    +
    Uploading
    +
    +
    + +
    +
    x
    +
    +
    +
    + +
    diff --git a/fusion_rental/views/menus.xml b/fusion_rental/views/menus.xml index 09f495e..0e4e809 100644 --- a/fusion_rental/views/menus.xml +++ b/fusion_rental/views/menus.xml @@ -13,7 +13,7 @@ diff --git a/fusion_rental/views/portal_sale_rental_override.xml b/fusion_rental/views/portal_sale_rental_override.xml new file mode 100644 index 0000000..e58b57c --- /dev/null +++ b/fusion_rental/views/portal_sale_rental_override.xml @@ -0,0 +1,76 @@ + + + + + + + diff --git a/fusion_rental/views/res_config_settings_views.xml b/fusion_rental/views/res_config_settings_views.xml index 87a284a..60eae13 100644 --- a/fusion_rental/views/res_config_settings_views.xml +++ b/fusion_rental/views/res_config_settings_views.xml @@ -7,7 +7,7 @@ - +
    @@ -31,6 +31,62 @@
    + +
    +
    + +
    +
    + Only needed if Fusion Claims is not installed. Enable the + Places API and Geocoding API in Google Cloud Console. +
    +
    +
    + + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/fusion_rental/views/sale_order_views.xml b/fusion_rental/views/sale_order_views.xml index 2196ce7..33b111c 100644 --- a/fusion_rental/views/sale_order_views.xml +++ b/fusion_rental/views/sale_order_views.xml @@ -7,6 +7,46 @@ + +
    + + + + +
    + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + +
    +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + +
    + + + + +
    +
    + + @@ -147,6 +221,8 @@
    + +
    + + + invisible="not is_rental_order"> - + - + + - - - - - - - - - - - - - + + diff --git a/fusion_rental/wizard/__init__.py b/fusion_rental/wizard/__init__.py index 239a8c8..c1554f1 100644 --- a/fusion_rental/wizard/__init__.py +++ b/fusion_rental/wizard/__init__.py @@ -1,2 +1,3 @@ from . import manual_renewal_wizard from . import deposit_deduction_wizard +from . import rental_return_wizard diff --git a/fusion_rental/wizard/deposit_deduction_wizard.py b/fusion_rental/wizard/deposit_deduction_wizard.py index f870f79..23cb309 100644 --- a/fusion_rental/wizard/deposit_deduction_wizard.py +++ b/fusion_rental/wizard/deposit_deduction_wizard.py @@ -1,10 +1,14 @@ +import logging + from odoo import _, api, fields, models from odoo.exceptions import UserError +_logger = logging.getLogger(__name__) -class DepositDeductionWizard(models.TransientModel): + +class DepositProcessWizard(models.TransientModel): _name = 'deposit.deduction.wizard' - _description = 'Security Deposit Deduction' + _description = 'Process Security Deposit' order_id = fields.Many2one( 'sale.order', @@ -12,53 +16,406 @@ class DepositDeductionWizard(models.TransientModel): required=True, readonly=True, ) + partner_id = fields.Many2one( + related='order_id.partner_id', + string="Customer", + ) deposit_total = fields.Float( string="Deposit Amount", readonly=True, ) + + action_type = fields.Selection( + selection=[ + ('full_refund', "Full Refund"), + ('partial_refund', "Partial Refund (Deduction for Damages)"), + ('no_refund', "Full Deduction (Damages Exceed Deposit)"), + ('sold', "Customer Purchased Rental Product"), + ], + string="Action", + required=True, + default='full_refund', + ) + 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, + string="Reason", ) remaining_preview = fields.Float( string="Remaining to Refund", - compute='_compute_remaining_preview', + compute='_compute_previews', ) overage_preview = fields.Float( string="Additional Invoice Amount", - compute='_compute_remaining_preview', - help="Amount exceeding the deposit that will be invoiced to the customer.", + compute='_compute_previews', + ) + refund_preview = fields.Float( + string="Refund Amount", + compute='_compute_previews', + ) + has_card_on_file = fields.Boolean( + string="Card on File", + compute='_compute_has_card', ) - @api.depends('deposit_total', 'deduction_amount') - def _compute_remaining_preview(self): + @api.depends('deposit_total', 'deduction_amount', 'action_type') + def _compute_previews(self): for wizard in self: - diff = wizard.deposit_total - wizard.deduction_amount - if diff >= 0: - wizard.remaining_preview = diff + if wizard.action_type == 'full_refund': + wizard.refund_preview = wizard.deposit_total + wizard.remaining_preview = wizard.deposit_total wizard.overage_preview = 0.0 - else: + elif wizard.action_type == 'sold': + wizard.refund_preview = wizard.deposit_total + wizard.remaining_preview = wizard.deposit_total + wizard.overage_preview = 0.0 + elif wizard.action_type == 'partial_refund': + diff = wizard.deposit_total - wizard.deduction_amount + if diff >= 0: + wizard.remaining_preview = diff + wizard.refund_preview = diff + wizard.overage_preview = 0.0 + else: + wizard.remaining_preview = 0.0 + wizard.refund_preview = 0.0 + wizard.overage_preview = abs(diff) + elif wizard.action_type == 'no_refund': wizard.remaining_preview = 0.0 - wizard.overage_preview = abs(diff) + wizard.refund_preview = 0.0 + wizard.overage_preview = max( + wizard.deduction_amount - wizard.deposit_total, 0.0, + ) + else: + wizard.refund_preview = 0.0 + wizard.remaining_preview = 0.0 + wizard.overage_preview = 0.0 - def action_confirm_deduction(self): + @api.depends('order_id') + def _compute_has_card(self): + for wizard in self: + wizard.has_card_on_file = bool( + wizard.order_id and wizard.order_id.rental_payment_token_id + ) + + def action_confirm(self): + """Dispatch to the appropriate deposit processing path.""" self.ensure_one() + order = self.order_id + + if self.action_type == 'full_refund': + return self._process_full_refund(order) + elif self.action_type == 'partial_refund': + return self._process_partial_refund(order) + elif self.action_type == 'no_refund': + return self._process_no_refund(order) + elif self.action_type == 'sold': + return self._process_sold(order) + + return {'type': 'ir.actions.act_window_close'} + + def _process_full_refund(self, order): + """Full deposit refund: credit note, Poynt refund, close rental.""" + invoice = order.rental_deposit_invoice_id + if not invoice: + raise UserError(_("No deposit invoice found.")) + + credit_note = self._create_deposit_credit_note( + order, invoice, invoice.amount_total, + _("Security deposit full refund for %s", order.name), + ) + + if credit_note: + self._process_poynt_refund(order, credit_note) + + order.rental_deposit_status = 'refunded' + order._send_deposit_refund_email() + + order.message_post(body=_( + "Security deposit fully refunded: %s", + self._format_amount(invoice.amount_total, order), + )) + + self._close_rental(order) + return {'type': 'ir.actions.act_window_close'} + + def _process_partial_refund(self, order): + """Partial refund with deduction for damages.""" if self.deduction_amount <= 0: raise UserError(_("Deduction amount must be greater than zero.")) + if not self.reason: + raise UserError(_("A reason is required for deductions.")) + + invoice = order.rental_deposit_invoice_id + if not invoice: + raise UserError(_("No deposit invoice found.")) + + deposit_total = invoice.amount_total + + if self.deduction_amount >= deposit_total: + return self._process_no_refund(order) + + refund_amount = deposit_total - self.deduction_amount + + credit_note = self._create_deposit_credit_note( + order, invoice, refund_amount, + _("Partial deposit refund for %s (deduction: %s)", + order.name, + self._format_amount(self.deduction_amount, order)), + ) + + if credit_note: + self._process_poynt_refund(order, credit_note) + + order.rental_deposit_status = 'deducted' + order._send_deposit_refund_email() - 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}, - ), + "Security deposit partial refund: %s refunded, %s deducted.\nReason: %s", + self._format_amount(refund_amount, order), + self._format_amount(self.deduction_amount, order), self.reason, )) + + self._close_rental(order) return {'type': 'ir.actions.act_window_close'} + + def _process_no_refund(self, order): + """Full deduction: no refund, create overage invoice if needed.""" + if not self.reason: + raise UserError(_("A reason is required for deductions.")) + + invoice = order.rental_deposit_invoice_id + deposit_total = invoice.amount_total if invoice else 0.0 + overage = self.deduction_amount - deposit_total if self.deduction_amount > deposit_total else 0.0 + + order.rental_deposit_status = 'deducted' + + if overage > 0: + damage_inv = order._create_damage_invoice(overage) + if damage_inv and order.rental_payment_token_id: + ok = order._collect_token_payment_for_invoice(damage_inv) + if ok: + order._send_invoice_with_receipt(damage_inv, 'damage') + else: + order._notify_staff_manual_payment(damage_inv) + elif damage_inv: + order._send_invoice_with_receipt(damage_inv, 'damage') + + order.message_post(body=_( + "Security deposit fully deducted. Deduction: %s.%s\nReason: %s", + self._format_amount(self.deduction_amount, order), + _(" Overage invoice: %s", self._format_amount(overage, order)) if overage > 0 else '', + self.reason, + )) + + self._close_rental(order) + return {'type': 'ir.actions.act_window_close'} + + def _process_sold(self, order): + """Customer purchased the rental: full deposit refund, mark as sold.""" + invoice = order.rental_deposit_invoice_id + + if invoice: + credit_note = self._create_deposit_credit_note( + order, invoice, invoice.amount_total, + _("Deposit refund - customer purchased rental product %s", order.name), + ) + if credit_note: + self._process_poynt_refund(order, credit_note) + + order.rental_deposit_status = 'refunded' + order.rental_purchase_interest = True + order._send_deposit_refund_email() + + order.message_post(body=_( + "Customer purchased rental product. Security deposit fully refunded." + )) + + order.activity_schedule( + 'mail.mail_activity_data_todo', + date_deadline=fields.Date.today(), + summary=_("Rental product sold - %s", order.name), + note=_( + "Customer %s purchased the rental product from %s. " + "Process the sale order and schedule delivery/pickup.", + order.partner_id.name, order.name, + ), + user_id=order.user_id.id or self.env.uid, + ) + + self._close_rental(order) + return {'type': 'ir.actions.act_window_close'} + + def _create_deposit_credit_note(self, order, invoice, amount, ref): + """Create and post a credit note for the deposit invoice.""" + if not invoice or invoice.payment_state not in ('paid', 'in_payment'): + _logger.warning( + "Cannot create credit note: invoice %s state=%s payment=%s", + invoice.name if invoice else 'None', + invoice.state if invoice else 'N/A', + invoice.payment_state if invoice else 'N/A', + ) + return None + + credit_notes = invoice._reverse_moves( + default_values_list=[{'ref': ref}], + ) + if not credit_notes: + return None + + credit_note = credit_notes[:1] + + if amount != invoice.amount_total: + for line in credit_note.invoice_line_ids: + line.price_unit = amount / max(line.quantity, 1) + + credit_note.action_post() + return credit_note + + def _process_poynt_refund(self, order, credit_note): + """Process refund via Poynt referenced refund for the credit note.""" + if not credit_note: + return + + try: + orig_tx = credit_note._get_original_poynt_transaction() + except Exception: + orig_tx = None + + if not orig_tx: + _logger.warning( + "No original Poynt transaction for deposit refund on %s. " + "Credit note created but refund must be processed manually.", + order.name, + ) + order._notify_staff_manual_payment(credit_note) + return + + provider = orig_tx.provider_id.sudo() + from odoo.addons.fusion_poynt import utils as poynt_utils + + 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'] + break + except Exception: + pass + + minor_amount = poynt_utils.format_poynt_amount( + abs(credit_note.amount_total), credit_note.currency_id, + ) + + refund_payload = { + 'action': 'REFUND', + 'parentId': parent_txn_id, + 'fundingSource': {'type': 'CREDIT_DEBIT'}, + 'amounts': { + 'transactionAmount': minor_amount, + 'orderAmount': minor_amount, + 'currency': credit_note.currency_id.name, + }, + 'context': { + 'source': 'WEB', + 'sourceApp': 'odoo.fusion_rental', + }, + 'notes': f'Deposit refund for {order.name} via {credit_note.name}', + } + + try: + result = provider._poynt_make_request( + 'POST', 'transactions', payload=refund_payload, + ) + except Exception as e: + _logger.error( + "Poynt deposit refund failed for %s: %s", order.name, e, + ) + order._notify_staff_manual_payment(credit_note) + return + + refund_status = result.get('status', '') + refund_txn_id = result.get('id', '') + + if refund_status in ('DECLINED', 'FAILED'): + _logger.warning( + "Poynt deposit refund declined for %s: %s", + order.name, refund_status, + ) + order._notify_staff_manual_payment(credit_note) + return + + PaymentMethod = self.env['payment.method'].sudo().with_context( + active_test=False, + ) + payment_method = PaymentMethod.search( + [('code', '=', 'card')], limit=1, + ) or PaymentMethod.search( + [('code', 'in', ('visa', 'mastercard'))], limit=1, + ) + + refund_tx = self.env['payment.transaction'].sudo().create({ + 'provider_id': provider.id, + 'payment_method_id': payment_method.id if payment_method else False, + 'amount': -abs(credit_note.amount_total), + 'currency_id': credit_note.currency_id.id, + 'partner_id': order.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, credit_note.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) + + if ( + credit_note.payment_state not in ('paid', 'in_payment') + and refund_tx.payment_id + ): + try: + (refund_tx.payment_id.move_id.line_ids + credit_note.line_ids).filtered( + lambda line: line.account_id == refund_tx.payment_id.destination_account_id + and not line.reconciled + ).reconcile() + except Exception as e: + _logger.warning( + "Fallback reconciliation failed for %s: %s", + order.name, e, + ) + + credit_note.sudo().poynt_refunded = True + credit_note.sudo().message_post(body=_( + "Deposit refund processed via Poynt. Amount: %s. " + "Transaction ID: %s.", + self._format_amount(abs(credit_note.amount_total), order), + refund_txn_id or 'N/A', + )) + + def _close_rental(self, order): + """Close the rental after deposit processing.""" + if not order.rental_closed: + try: + order.action_close_rental() + except Exception as e: + _logger.error( + "Auto-close after deposit processing failed for %s: %s", + order.name, e, + ) + + def _format_amount(self, amount, order): + return self.env['ir.qweb.field.monetary'].value_to_html( + amount, {'display_currency': order.currency_id}, + ) diff --git a/fusion_rental/wizard/deposit_deduction_wizard_views.xml b/fusion_rental/wizard/deposit_deduction_wizard_views.xml index 2a1fe42..2f4846b 100644 --- a/fusion_rental/wizard/deposit_deduction_wizard_views.xml +++ b/fusion_rental/wizard/deposit_deduction_wizard_views.xml @@ -5,28 +5,70 @@ deposit.deduction.wizard.form deposit.deduction.wizard - + - - + + - - + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + +
    -
    diff --git a/fusion_rental/wizard/manual_renewal_wizard.py b/fusion_rental/wizard/manual_renewal_wizard.py index 4df4f09..a626b82 100644 --- a/fusion_rental/wizard/manual_renewal_wizard.py +++ b/fusion_rental/wizard/manual_renewal_wizard.py @@ -42,6 +42,40 @@ class ManualRenewalWizard(models.TransientModel): compute='_compute_amount_preview', ) + payment_token_id = fields.Many2one( + 'payment.token', + string="Card on File", + domain="[('partner_id', '=', partner_id)]", + help="Select a stored card to charge automatically. " + "Leave empty to open the manual payment wizard.", + ) + use_card_on_file = fields.Boolean( + string="Charge Card on File", + default=False, + compute='_compute_use_card_on_file', + store=True, + readonly=False, + ) + + previous_start_date = fields.Datetime( + string="Previous Start (rollback)", + readonly=True, + ) + previous_return_date = fields.Datetime( + string="Previous Return (rollback)", + readonly=True, + ) + renewal_invoice_id = fields.Many2one( + 'account.move', + string="Renewal Invoice", + readonly=True, + ) + renewal_log_id = fields.Many2one( + 'rental.renewal.log', + string="Renewal Log", + readonly=True, + ) + @api.depends('order_id', 'new_start_date', 'new_return_date') def _compute_amount_preview(self): for wizard in self: @@ -50,8 +84,13 @@ class ManualRenewalWizard(models.TransientModel): else: wizard.amount_preview = 0.0 + @api.depends('payment_token_id') + def _compute_use_card_on_file(self): + for wizard in self: + wizard.use_card_on_file = bool(wizard.payment_token_id) + def action_confirm_renewal(self): - """Confirm the manual renewal: extend dates, invoice, and collect payment.""" + """Confirm the manual renewal: extend dates, create invoice, and collect payment.""" self.ensure_one() order = self.order_id @@ -64,6 +103,11 @@ class ManualRenewalWizard(models.TransientModel): old_start = order.rental_start_date old_return = order.rental_return_date + self.write({ + 'previous_start_date': old_start, + 'previous_return_date': old_return, + }) + order.write({ 'rental_start_date': self.new_start_date, 'rental_return_date': self.new_return_date, @@ -71,8 +115,8 @@ class ManualRenewalWizard(models.TransientModel): order._recompute_rental_prices() invoice = order._create_renewal_invoice() - if invoice: - invoice.action_post() + if not invoice: + raise UserError(_("Could not create renewal invoice.")) renewal_log = self.env['rental.renewal.log'].create({ 'order_id': order.id, @@ -81,21 +125,77 @@ class ManualRenewalWizard(models.TransientModel): '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, + 'invoice_id': invoice.id, 'renewal_type': 'manual', - 'state': 'done', + 'state': 'draft', 'payment_status': 'pending', }) + self.write({ + 'renewal_invoice_id': invoice.id, + 'renewal_log_id': renewal_log.id, + }) + order.write({ 'rental_renewal_count': order.rental_renewal_count + 1, 'rental_reminder_sent': False, }) + if self.use_card_on_file and self.payment_token_id: + invoice.action_post() + ok = order._collect_token_payment_for_invoice(invoice) + if ok: + renewal_log.write({ + 'state': 'done', + 'payment_status': 'paid', + }) + order._send_invoice_with_receipt(invoice, 'renewal') + order._send_renewal_confirmation_email(renewal_log, True) + return {'type': 'ir.actions.act_window_close'} + else: + renewal_log.write({ + 'state': 'done', + 'payment_status': 'failed', + }) + order._send_renewal_confirmation_email(renewal_log, False) + order._notify_staff_manual_payment(invoice) + return {'type': 'ir.actions.act_window_close'} + + invoice.action_post() + renewal_log.write({'state': 'done'}) order._send_renewal_confirmation_email(renewal_log, False) - if invoice: - inv = invoice.with_user(self.env.uid) - return inv.action_open_poynt_payment_wizard() + inv = invoice.with_user(self.env.user) + return inv.action_open_poynt_payment_wizard() + def action_cancel_renewal(self): + """Cancel the renewal: revert dates and void the invoice.""" + self.ensure_one() + order = self.order_id + + if self.renewal_invoice_id: + inv = self.renewal_invoice_id + if inv.state == 'posted' and inv.payment_state in ('not_paid', 'partial'): + inv.button_draft() + if inv.state == 'draft': + inv.button_cancel() + + if self.previous_start_date and self.previous_return_date: + order.write({ + 'rental_start_date': self.previous_start_date, + 'rental_return_date': self.previous_return_date, + }) + order._recompute_rental_prices() + + if self.renewal_log_id: + self.renewal_log_id.write({ + 'state': 'failed', + 'payment_status': 'failed', + 'notes': 'Cancelled by user.', + }) + + if order.rental_renewal_count > 0: + order.rental_renewal_count -= 1 + + order.message_post(body=_("Manual renewal cancelled and reverted.")) return {'type': 'ir.actions.act_window_close'} diff --git a/fusion_rental/wizard/manual_renewal_wizard_views.xml b/fusion_rental/wizard/manual_renewal_wizard_views.xml index c61e17b..bd5c9cb 100644 --- a/fusion_rental/wizard/manual_renewal_wizard_views.xml +++ b/fusion_rental/wizard/manual_renewal_wizard_views.xml @@ -1,16 +1,21 @@ - manual.renewal.wizard.form manual.renewal.wizard
    + + + + + + @@ -20,6 +25,7 @@ + @@ -29,15 +35,36 @@ + + + + + + + + + + + +
    diff --git a/fusion_rental/wizard/rental_return_wizard.py b/fusion_rental/wizard/rental_return_wizard.py new file mode 100644 index 0000000..acff3c1 --- /dev/null +++ b/fusion_rental/wizard/rental_return_wizard.py @@ -0,0 +1,230 @@ +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import float_compare + +_logger = logging.getLogger(__name__) + + +class RentalReturnWizard(models.TransientModel): + _name = 'rental.return.wizard' + _description = 'Rental Return & Inspection' + + order_id = fields.Many2one( + 'sale.order', + string="Rental Order", + required=True, + readonly=True, + ) + partner_id = fields.Many2one(related='order_id.partner_id') + currency_id = fields.Many2one(related='order_id.currency_id') + + line_ids = fields.One2many( + 'rental.return.wizard.line', + 'wizard_id', + string="Items to Return", + readonly=True, + ) + + inspection_condition = fields.Selection( + [ + ('good', 'Good - No Issues'), + ('fair', 'Fair - Minor Wear'), + ('damaged', 'Damaged'), + ], + string="Product Condition", + required=True, + ) + inspection_notes = fields.Text( + string="Inspection Notes", + help="Describe the condition of the returned items.", + ) + inspection_photo_ids = fields.Many2many( + 'ir.attachment', + 'rental_return_wizard_photo_rel', + 'wizard_id', + 'attachment_id', + string="Inspection Photos", + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + order_id = res.get('order_id') or self.env.context.get('default_order_id') + if not order_id: + return res + + order = self.env['sale.order'].browse(order_id) + precision = self.env['decimal.precision'].precision_get('Product Unit') + + lines_vals = [] + for line in order.order_line.filtered( + lambda r: r.is_rental + and r.product_type != 'combo' + and float_compare( + r.qty_delivered, r.qty_returned, + precision_digits=precision, + ) > 0 + ): + lines_vals.append((0, 0, { + 'sale_line_id': line.id, + 'product_id': line.product_id.id, + 'description': line.name, + 'qty_delivered': line.qty_delivered, + 'qty_returned': line.qty_returned, + 'qty_to_return': line.qty_delivered - line.qty_returned, + })) + + res['line_ids'] = lines_vals + return res + + def action_confirm(self): + """Validate inspection, apply results, and process the return.""" + self.ensure_one() + order = self.order_id + + if not self.inspection_photo_ids: + raise UserError(_( + "Inspection photos are required. Please attach at least one " + "photo showing the condition of the returned items." + )) + + if self.inspection_condition in ('fair', 'damaged') and not self.inspection_notes: + raise UserError(_( + "Please provide inspection notes describing the issue " + "when condition is '%s'.", + dict(self._fields['inspection_condition'].selection).get( + self.inspection_condition, self.inspection_condition, + ), + )) + + self._apply_inspection(order) + self._process_return(order) + + return {'type': 'ir.actions.act_window_close'} + + def _apply_inspection(self, order): + """Write inspection results to the sale order.""" + if self.inspection_condition == 'good': + status = 'passed' + else: + status = 'flagged' + + order.write({ + 'rental_inspection_status': status, + 'rental_inspection_notes': self.inspection_notes or '', + 'rental_inspection_photo_ids': [(6, 0, self.inspection_photo_ids.ids)], + }) + + if status == 'passed': + order.message_post(body=_( + "Return inspection completed: condition is good. " + "Security deposit refund process initiated." + )) + if order.rental_deposit_status == 'collected': + order._refund_security_deposit() + else: + condition_label = dict( + self._fields['inspection_condition'].selection, + ).get(self.inspection_condition, self.inspection_condition) + + order.message_post(body=_( + "Return inspection completed: condition is '%s'. " + "Flagged for review.\nNotes: %s", + condition_label, + self.inspection_notes or '-', + )) + + order.activity_schedule( + 'mail.mail_activity_data_todo', + date_deadline=fields.Date.today(), + summary=_("Return inspection flagged - %s", order.name), + note=_( + "Returned items from %s inspected as '%s'. " + "Review the inspection photos and process the deposit.\n" + "Notes: %s", + order.partner_id.name, + condition_label, + self.inspection_notes or '-', + ), + user_id=order.user_id.id or self.env.uid, + ) + order._send_damage_notification_email() + + def _process_return(self, order): + """Mark items as returned via stock picking or rental wizard.""" + picking = order.picking_ids.filtered( + lambda p: p.state == 'assigned' + and p.picking_type_code == 'incoming' + ) + + if picking: + picking = picking[:1] + for move in picking.move_ids.filtered( + lambda m: m.state not in ('done', 'cancel') + ): + if move.product_uom.is_zero(move.quantity): + move.quantity = move.product_uom_qty + try: + picking.with_context( + skip_sanity_check=False, + cancel_backorder=True, + ).button_validate() + except Exception as e: + _logger.error( + "Auto-validate return picking %s for %s failed: %s", + picking.name, order.name, e, + ) + raise UserError(_( + "Could not automatically process the return picking. " + "Please validate the return picking manually.\n\n" + "Inspection has been saved successfully.\n\n" + "Error: %s", str(e), + )) from e + return + + precision = self.env['decimal.precision'].precision_get( + 'Product Unit', + ) + for line in self.line_ids: + sol = line.sale_line_id + if float_compare( + sol.qty_delivered, sol.qty_returned, + precision_digits=precision, + ) > 0: + sol.qty_returned = sol.qty_delivered + + +class RentalReturnWizardLine(models.TransientModel): + _name = 'rental.return.wizard.line' + _description = 'Rental Return Wizard Line' + + wizard_id = fields.Many2one( + 'rental.return.wizard', + required=True, + ondelete='cascade', + ) + sale_line_id = fields.Many2one( + 'sale.order.line', + string="Order Line", + readonly=True, + ) + product_id = fields.Many2one( + 'product.product', + string="Product", + readonly=True, + ) + description = fields.Char(readonly=True) + qty_delivered = fields.Float( + string="Qty Delivered", + readonly=True, + ) + qty_returned = fields.Float( + string="Already Returned", + readonly=True, + ) + qty_to_return = fields.Float( + string="Qty to Return", + readonly=True, + ) diff --git a/fusion_rental/wizard/rental_return_wizard_views.xml b/fusion_rental/wizard/rental_return_wizard_views.xml new file mode 100644 index 0000000..38476c8 --- /dev/null +++ b/fusion_rental/wizard/rental_return_wizard_views.xml @@ -0,0 +1,74 @@ + + + + + rental.return.wizard.form + rental.return.wizard + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Click the attach button to add multiple photos. + Click any photo thumbnail to preview full size. +
    +
    + + + + +
    +
    + +
    +
    + +
    diff --git a/fusion_whitelabels/__init__.py b/fusion_whitelabels/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/fusion_whitelabels/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/fusion_whitelabels/__manifest__.py b/fusion_whitelabels/__manifest__.py new file mode 100644 index 0000000..a1ad3f9 --- /dev/null +++ b/fusion_whitelabels/__manifest__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +{ + "name": "Fusion Whitelabels", + "version": "19.0.1.3.0", + "category": "Website", + "summary": "Remove Odoo frontend promotional branding for portal and website pages.", + "description": """ + Fusion Whitelabels + ================== + + Persistent Odoo 19 whitelabel customizations: + - Removes "Connect with your software" portal promotions. + - Removes global "Powered by Odoo" website/footer promotions. + - Removes login-page "Powered by Odoo" footer link. + """, + "author": "Fusion", + "license": "LGPL-3", + "depends": ["portal", "sale", "purchase", "website", "website_sale"], + "data": [ + "views/fusion_whitelabels_templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "fusion_whitelabels/static/src/css/whitelabel.css", + ], + }, + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/fusion_whitelabels/static/src/css/whitelabel.css b/fusion_whitelabels/static/src/css/whitelabel.css new file mode 100644 index 0000000..1d65e80 --- /dev/null +++ b/fusion_whitelabels/static/src/css/whitelabel.css @@ -0,0 +1,3 @@ +.o-mail-Thread-jumpPresent { + display: none !important; +} diff --git a/fusion_whitelabels/views/fusion_whitelabels_templates.xml b/fusion_whitelabels/views/fusion_whitelabels_templates.xml new file mode 100644 index 0000000..c081f48 --- /dev/null +++ b/fusion_whitelabels/views/fusion_whitelabels_templates.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + +