From e71bc503f95699cd61d83125777e6ddc35f3cb1c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 25 Feb 2026 09:40:41 -0500 Subject: [PATCH] changes --- .../controllers/portal_main.py | 66 ++ fusion_claims/__manifest__.py | 2 +- fusion_claims/models/product_template.py | 77 ++ fusion_claims/models/technician_task.py | 83 +- .../report/report_rental_agreement.xml | 51 +- fusion_claims/scripts/cleanup_demo_pool.py | 191 +++ fusion_claims/scripts/import_demo_pool.py | 277 +++++ .../static/src/xml/fusion_task_map_view.xml | 6 +- fusion_claims/views/fusion_loaner_views.xml | 89 +- fusion_claims/views/technician_task_views.xml | 16 + fusion_poynt/__init__.py | 1 + fusion_poynt/__manifest__.py | 15 +- fusion_poynt/data/payment_provider_data.xml | 1 + .../data/poynt_receipt_email_template.xml | 97 ++ fusion_poynt/models/__init__.py | 2 + fusion_poynt/models/account_move.py | 203 ++++ fusion_poynt/models/payment_provider.py | 155 ++- fusion_poynt/models/payment_transaction.py | 457 +++++++- fusion_poynt/models/poynt_terminal.py | 55 +- fusion_poynt/models/sale_order.py | 60 + fusion_poynt/report/poynt_receipt_report.xml | 14 + .../report/poynt_receipt_templates.xml | 303 +++++ fusion_poynt/security/ir.model.access.csv | 4 + fusion_poynt/static/src/img/poynt_logo.png | Bin 0 -> 37613 bytes .../static/src/js/poynt_wizard_poll.js | 69 ++ .../static/src/xml/poynt_wizard_poll.xml | 27 + fusion_poynt/utils.py | 52 +- fusion_poynt/views/account_move_views.xml | 83 ++ fusion_poynt/views/payment_provider_views.xml | 1 + .../views/payment_transaction_views.xml | 40 + fusion_poynt/views/poynt_terminal_views.xml | 6 +- fusion_poynt/views/sale_order_views.xml | 22 + fusion_poynt/wizard/__init__.py | 4 + fusion_poynt/wizard/poynt_payment_wizard.py | 552 +++++++++ .../wizard/poynt_payment_wizard_views.xml | 135 +++ fusion_poynt/wizard/poynt_refund_wizard.py | 531 +++++++++ .../wizard/poynt_refund_wizard_views.xml | 112 ++ fusion_rental/__init__.py | 3 + fusion_rental/__manifest__.py | 43 + fusion_rental/controllers/__init__.py | 1 + fusion_rental/controllers/main.py | 305 +++++ fusion_rental/data/ir_cron_data.xml | 53 + fusion_rental/data/loyalty_program_data.xml | 23 + fusion_rental/data/mail_template_data.xml | 616 ++++++++++ fusion_rental/data/product_data.xml | 18 + fusion_rental/models/__init__.py | 6 + fusion_rental/models/cancellation_request.py | 123 ++ fusion_rental/models/renewal_log.py | 74 ++ fusion_rental/models/res_config_settings.py | 19 + fusion_rental/models/sale_order.py | 1040 +++++++++++++++++ fusion_rental/models/sale_order_line.py | 63 + fusion_rental/models/stock_warehouse.py | 10 + .../report/report_rental_agreement.xml | 399 +++++++ fusion_rental/security/ir.model.access.csv | 9 + fusion_rental/security/security.xml | 30 + fusion_rental/static/description/icon.png | Bin 0 -> 46317 bytes .../views/cancellation_request_views.xml | 89 ++ fusion_rental/views/menus.xml | 34 + .../views/portal_rental_inspection.xml | 146 +++ .../views/product_template_views.xml | 53 + fusion_rental/views/renewal_log_views.xml | 85 ++ .../views/res_config_settings_views.xml | 39 + fusion_rental/views/sale_order_views.xml | 202 ++++ fusion_rental/wizard/__init__.py | 2 + .../wizard/deposit_deduction_wizard.py | 64 + .../wizard/deposit_deduction_wizard_views.xml | 36 + fusion_rental/wizard/manual_renewal_wizard.py | 101 ++ .../wizard/manual_renewal_wizard_views.xml | 46 + fusion_ringcentral/models/rc_config.py | 28 + 69 files changed, 7537 insertions(+), 82 deletions(-) create mode 100644 fusion_claims/scripts/cleanup_demo_pool.py create mode 100644 fusion_claims/scripts/import_demo_pool.py create mode 100644 fusion_poynt/data/poynt_receipt_email_template.xml create mode 100644 fusion_poynt/models/account_move.py create mode 100644 fusion_poynt/models/sale_order.py create mode 100644 fusion_poynt/report/poynt_receipt_report.xml create mode 100644 fusion_poynt/report/poynt_receipt_templates.xml create mode 100644 fusion_poynt/static/src/img/poynt_logo.png create mode 100644 fusion_poynt/static/src/js/poynt_wizard_poll.js create mode 100644 fusion_poynt/static/src/xml/poynt_wizard_poll.xml create mode 100644 fusion_poynt/views/account_move_views.xml create mode 100644 fusion_poynt/views/payment_transaction_views.xml create mode 100644 fusion_poynt/views/sale_order_views.xml create mode 100644 fusion_poynt/wizard/__init__.py create mode 100644 fusion_poynt/wizard/poynt_payment_wizard.py create mode 100644 fusion_poynt/wizard/poynt_payment_wizard_views.xml create mode 100644 fusion_poynt/wizard/poynt_refund_wizard.py create mode 100644 fusion_poynt/wizard/poynt_refund_wizard_views.xml create mode 100644 fusion_rental/__init__.py create mode 100644 fusion_rental/__manifest__.py create mode 100644 fusion_rental/controllers/__init__.py create mode 100644 fusion_rental/controllers/main.py create mode 100644 fusion_rental/data/ir_cron_data.xml create mode 100644 fusion_rental/data/loyalty_program_data.xml create mode 100644 fusion_rental/data/mail_template_data.xml create mode 100644 fusion_rental/data/product_data.xml create mode 100644 fusion_rental/models/__init__.py create mode 100644 fusion_rental/models/cancellation_request.py create mode 100644 fusion_rental/models/renewal_log.py create mode 100644 fusion_rental/models/res_config_settings.py create mode 100644 fusion_rental/models/sale_order.py create mode 100644 fusion_rental/models/sale_order_line.py create mode 100644 fusion_rental/models/stock_warehouse.py create mode 100644 fusion_rental/report/report_rental_agreement.xml create mode 100644 fusion_rental/security/ir.model.access.csv create mode 100644 fusion_rental/security/security.xml create mode 100644 fusion_rental/static/description/icon.png create mode 100644 fusion_rental/views/cancellation_request_views.xml create mode 100644 fusion_rental/views/menus.xml create mode 100644 fusion_rental/views/portal_rental_inspection.xml create mode 100644 fusion_rental/views/product_template_views.xml create mode 100644 fusion_rental/views/renewal_log_views.xml create mode 100644 fusion_rental/views/res_config_settings_views.xml create mode 100644 fusion_rental/views/sale_order_views.xml create mode 100644 fusion_rental/wizard/__init__.py create mode 100644 fusion_rental/wizard/deposit_deduction_wizard.py create mode 100644 fusion_rental/wizard/deposit_deduction_wizard_views.xml create mode 100644 fusion_rental/wizard/manual_renewal_wizard.py create mode 100644 fusion_rental/wizard/manual_renewal_wizard_views.xml diff --git a/fusion_authorizer_portal/controllers/portal_main.py b/fusion_authorizer_portal/controllers/portal_main.py index b97938f..eeef7c0 100644 --- a/fusion_authorizer_portal/controllers/portal_main.py +++ b/fusion_authorizer_portal/controllers/portal_main.py @@ -2474,3 +2474,69 @@ class AuthorizerPortal(CustomerPortal): _logger.info(f"Attached video to assessment {assessment.reference}") except Exception as e: _logger.warning(f"Failed to attach video to assessment {assessment.reference}: {e}") + + # ================================================================= + # RENTAL PICKUP INSPECTION (added by fusion_rental) + # ================================================================= + + @http.route( + '/my/technician/rental-inspection/', + type='http', auth='user', website=True, + ) + def rental_inspection_page(self, task_id, **kw): + """Render the rental pickup inspection form for the technician.""" + user = request.env.user + task = request.env['fusion.technician.task'].sudo().browse(task_id) + + if ( + not task.exists() + or task.technician_id.id != user.id + or task.task_type != 'pickup' + ): + return request.redirect('/my') + + return request.render( + 'fusion_rental.portal_rental_inspection', + { + 'task': task, + 'order': task.sale_order_id, + 'page_name': 'rental_inspection', + }, + ) + + @http.route( + '/my/technician/rental-inspection//submit', + type='json', auth='user', methods=['POST'], + ) + def rental_inspection_submit(self, task_id, **kwargs): + """Save the rental inspection results.""" + user = request.env.user + task = request.env['fusion.technician.task'].sudo().browse(task_id) + + if ( + not task.exists() + or task.technician_id.id != user.id + or task.task_type != 'pickup' + ): + return {'success': False, 'error': 'Access denied.'} + + condition = kwargs.get('condition', '') + notes = kwargs.get('notes', '') + photo_ids = kwargs.get('photo_ids', []) + + if not condition: + return {'success': False, 'error': 'Please select a condition.'} + + vals = { + 'rental_inspection_condition': condition, + 'rental_inspection_notes': notes, + 'rental_inspection_completed': True, + } + if photo_ids: + vals['rental_inspection_photo_ids'] = [(6, 0, photo_ids)] + task.write(vals) + + return { + 'success': True, + 'message': 'Inspection saved. You can now complete the task.', + } diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 30e9c95..90f14d1 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.6.0.0', + 'version': '19.0.7.0.0', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ diff --git a/fusion_claims/models/product_template.py b/fusion_claims/models/product_template.py index 770eb5e..7b7110f 100644 --- a/fusion_claims/models/product_template.py +++ b/fusion_claims/models/product_template.py @@ -61,6 +61,61 @@ class ProductTemplate(models.Model): help='Rental price per month if loaner converts to rental', ) + # ========================================================================== + # LOANER EQUIPMENT FIELDS + # ========================================================================== + + x_fc_equipment_type = fields.Selection([ + ('type_1_walker', 'Type 1 Walker'), + ('type_2_mw', 'Type 2 MW'), + ('type_2_pw', 'Type 2 PW'), + ('type_2_walker', 'Type 2 Walker'), + ('type_3_mw', 'Type 3 MW'), + ('type_3_pw', 'Type 3 PW'), + ('type_3_walker', 'Type 3 Walker'), + ('type_4_mw', 'Type 4 MW'), + ('type_5_mw', 'Type 5 MW'), + ('ceiling_lift', 'Ceiling Lift'), + ('mobility_scooter', 'Mobility Scooter'), + ('patient_lift', 'Patient Lift'), + ('transport_wheelchair', 'Transport Wheelchair'), + ('standard_wheelchair', 'Standard Wheelchair'), + ('power_wheelchair', 'Power Wheelchair'), + ('cushion', 'Cushion'), + ('backrest', 'Backrest'), + ('stairlift', 'Stairlift'), + ('others', 'Others'), + ], string='Equipment Type') + + x_fc_wheelchair_category = fields.Selection([ + ('type_1', 'Type 1'), + ('type_2', 'Type 2'), + ('type_3', 'Type 3'), + ('type_4', 'Type 4'), + ('type_5', 'Type 5'), + ], string='Wheelchair Category') + + x_fc_seat_width = fields.Char(string='Seat Width') + x_fc_seat_depth = fields.Char(string='Seat Depth') + x_fc_seat_height = fields.Char(string='Seat Height') + + x_fc_storage_location = fields.Selection([ + ('warehouse', 'Warehouse'), + ('westin_brampton', 'Westin Brampton'), + ('mobility_etobicoke', 'Mobility Etobicoke'), + ('scarborough_storage', 'Scarborough Storage'), + ('client_loaned', 'Client/Loaned'), + ('rented_out', 'Rented Out'), + ], string='Storage Location') + + x_fc_listing_type = fields.Selection([ + ('owned', 'Owned'), + ('borrowed', 'Borrowed'), + ], string='Listing Type') + + x_fc_asset_number = fields.Char(string='Asset Number') + x_fc_package_info = fields.Text(string='Package Information') + # ========================================================================== # COMPUTED FIELDS # ========================================================================== @@ -107,3 +162,25 @@ class ProductTemplate(models.Model): return self.default_code or '' + # ========================================================================== + # SECURITY DEPOSIT (added by fusion_rental) + # ========================================================================== + + x_fc_security_deposit_type = fields.Selection( + [ + ('fixed', 'Fixed Amount'), + ('percentage', 'Percentage of Rental Price'), + ], + string='Security Deposit Type', + help='How the security deposit is calculated for this rental product.', + ) + x_fc_security_deposit_amount = fields.Float( + string='Security Deposit Amount', + digits='Product Price', + help='Fixed dollar amount for the security deposit.', + ) + x_fc_security_deposit_percent = fields.Float( + string='Security Deposit (%)', + help='Percentage of the rental line price to charge as deposit.', + ) + diff --git a/fusion_claims/models/technician_task.py b/fusion_claims/models/technician_task.py index e885548..9d4504e 100644 --- a/fusion_claims/models/technician_task.py +++ b/fusion_claims/models/technician_task.py @@ -388,6 +388,30 @@ class FusionTechnicianTask(models.Model): string='Notified At', ) + # ------------------------------------------------------------------ + # RENTAL INSPECTION (added by fusion_rental) + # ------------------------------------------------------------------ + rental_inspection_condition = fields.Selection([ + ('excellent', 'Excellent'), + ('good', 'Good'), + ('fair', 'Fair'), + ('damaged', 'Damaged'), + ], string='Inspection Condition') + rental_inspection_notes = fields.Text( + string='Inspection Notes', + ) + rental_inspection_photo_ids = fields.Many2many( + 'ir.attachment', + 'technician_task_inspection_photo_rel', + 'task_id', + 'attachment_id', + string='Inspection Photos', + ) + rental_inspection_completed = fields.Boolean( + string='Inspection Completed', + default=False, + ) + # ------------------------------------------------------------------ # COMPUTED FIELDS # ------------------------------------------------------------------ @@ -1608,22 +1632,37 @@ class FusionTechnicianTask(models.Model): 'res_id': self.purchase_order_id.id, } + def _is_rental_pickup_task(self): + """Check if this is a pickup task for a rental order.""" + self.ensure_one() + return ( + self.task_type == 'pickup' + and self.sale_order_id + and self.sale_order_id.is_rental_order + ) + def action_complete_task(self): """Mark task as Completed.""" for task in self: if task.status not in ('in_progress', 'en_route', 'scheduled'): raise UserError(_("Task must be in progress to complete.")) + + if task._is_rental_pickup_task() and not task.rental_inspection_completed: + raise UserError(_( + "Rental pickup tasks require a security inspection before " + "completion. Please complete the inspection from the " + "technician portal first." + )) + task.with_context(skip_travel_recalc=True).write({ 'status': 'completed', 'completion_datetime': fields.Datetime.now(), }) task._post_status_message('completed') - # Post completion notes to linked order chatter if task.completion_notes and (task.sale_order_id or task.purchase_order_id): task._post_completion_to_linked_order() - # Notify the person who scheduled the task task._notify_scheduler_on_completion() - # Auto-advance ODSP status for delivery tasks + if (task.task_type == 'delivery' and task.sale_order_id and task.sale_order_id.x_fc_is_odsp_sale @@ -1633,6 +1672,44 @@ class FusionTechnicianTask(models.Model): "Delivery task completed by technician. Order marked as delivered.", ) + if task._is_rental_pickup_task(): + task._apply_rental_inspection_results() + + def _apply_rental_inspection_results(self): + """Write inspection results from the task back to the rental order.""" + self.ensure_one() + order = self.sale_order_id + if not order or not order.is_rental_order: + return + + inspection_status = 'passed' + if self.rental_inspection_condition in ('fair', 'damaged'): + inspection_status = 'flagged' + + vals = { + 'rental_inspection_status': inspection_status, + 'rental_inspection_notes': self.rental_inspection_notes or '', + } + if self.rental_inspection_photo_ids: + vals['rental_inspection_photo_ids'] = [(6, 0, self.rental_inspection_photo_ids.ids)] + order.write(vals) + + if inspection_status == 'passed': + order._refund_security_deposit() + elif inspection_status == 'flagged': + order.activity_schedule( + 'mail.mail_activity_data_todo', + date_deadline=fields.Date.today(), + summary=_("Review rental inspection: %s", order.name), + note=_( + "Technician flagged rental pickup for %s. " + "Condition: %s. Please review inspection photos and notes.", + order.partner_id.name, + self.rental_inspection_condition or 'Unknown', + ), + user_id=order.user_id.id or self.env.uid, + ) + def action_cancel_task(self): """Cancel the task. Sends cancellation email and reverts sale order if delivery.""" for task in self: diff --git a/fusion_claims/report/report_rental_agreement.xml b/fusion_claims/report/report_rental_agreement.xml index f30ae1f..4b424bd 100644 --- a/fusion_claims/report/report_rental_agreement.xml +++ b/fusion_claims/report/report_rental_agreement.xml @@ -270,13 +270,18 @@ Card #: - - - - - - - - - - + + **** **** **** 1234 + + + + - + + - + + - + + @@ -286,14 +291,25 @@ / CVV: - - Security Deposit: $___________ + *** + + Security Deposit: + + $0.00 + + $___________ + Cardholder: -
+ + Name + + +
+
@@ -316,15 +332,24 @@
FULL NAME (PRINT)
-
+ +
Name
+
+
SIGNATURE
-
+ + + +
DATE
-
+ +
Date
+
+
diff --git a/fusion_claims/scripts/cleanup_demo_pool.py b/fusion_claims/scripts/cleanup_demo_pool.py new file mode 100644 index 0000000..1a07988 --- /dev/null +++ b/fusion_claims/scripts/cleanup_demo_pool.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Demo Pool Cleanup Script + +Removes the Studio-created x_demo_pool_tracking model and all related +database artifacts (tables, ir.model entries, fields, views, menus, etc.). + +WARNING: Only run this AFTER verifying the import_demo_pool.py migration +was successful and all data is correct in Loaner Products. + +Run via Odoo shell: + docker exec -i odoo-mobility-app odoo shell -d mobility < cleanup_demo_pool.py + +Copyright 2024-2026 Nexa Systems Inc. +License OPL-1 (Odoo Proprietary License v1.0) +""" + +import logging + +_logger = logging.getLogger(__name__) + +DEMO_POOL_MODELS = [ + 'x_demo_pool_tracking', + 'x_demo_pool_tracking_line_4a032', + 'x_demo_pool_tracking_line_629cc', + 'x_demo_pool_tracking_line_b4ec9', + 'x_demo_pool_tracking_stage', + 'x_demo_pool_tracking_tag', +] + +DEMO_POOL_TABLES = DEMO_POOL_MODELS + [ + 'x_demo_pool_tracking_tag_rel', +] + +PRODUCT_TEMPLATE_STUDIO_FIELDS_TO_REMOVE = [ + 'x_studio_many2one_field_V8rnk', +] + + +def run_cleanup(env): + cr = env.cr + + print(f"\n{'='*60}") + print("Demo Pool Cleanup") + print(f"{'='*60}") + print() + + # ===================================================================== + # Step 1: Remove ir.ui.menu entries referencing demo pool actions + # ===================================================================== + print("[1/7] Removing menu items...") + cr.execute(""" + DELETE FROM ir_ui_menu + WHERE action IN ( + SELECT CONCAT('ir.actions.act_window,', id) + FROM ir_act_window + WHERE res_model IN %s + ) + """, (tuple(DEMO_POOL_MODELS),)) + print(f" Removed {cr.rowcount} menu items") + + # ===================================================================== + # Step 2: Remove ir.actions.act_window entries + # ===================================================================== + print("[2/7] Removing window actions...") + cr.execute(""" + DELETE FROM ir_act_window WHERE res_model IN %s + """, (tuple(DEMO_POOL_MODELS),)) + print(f" Removed {cr.rowcount} window actions") + + # ===================================================================== + # Step 3: Remove ir.ui.view entries + # ===================================================================== + print("[3/7] Removing views...") + cr.execute(""" + DELETE FROM ir_ui_view WHERE model IN %s + """, (tuple(DEMO_POOL_MODELS),)) + print(f" Removed {cr.rowcount} views") + + # ===================================================================== + # Step 4: Remove ir.model.access entries + # ===================================================================== + print("[4/7] Removing access rules...") + cr.execute(""" + SELECT id FROM ir_model WHERE model IN %s + """, (tuple(DEMO_POOL_MODELS),)) + model_ids = [row[0] for row in cr.fetchall()] + + if model_ids: + cr.execute(""" + DELETE FROM ir_model_access WHERE model_id IN %s + """, (tuple(model_ids),)) + print(f" Removed {cr.rowcount} access rules") + else: + print(" No model IDs found (already cleaned?)") + + # ===================================================================== + # Step 5: Remove ir.model.fields and ir.model entries + # ===================================================================== + print("[5/7] Removing field definitions and model registry entries...") + + if model_ids: + cr.execute(""" + DELETE FROM ir_model_fields_selection + WHERE field_id IN ( + SELECT id FROM ir_model_fields WHERE model_id IN %s + ) + """, (tuple(model_ids),)) + print(f" Removed {cr.rowcount} field selection values") + + cr.execute(""" + DELETE FROM ir_model_fields WHERE model_id IN %s + """, (tuple(model_ids),)) + print(f" Removed {cr.rowcount} field definitions") + + cr.execute(""" + DELETE FROM ir_model_constraint WHERE model IN %s + """, (tuple(model_ids),)) + print(f" Removed {cr.rowcount} constraints") + + cr.execute(""" + DELETE FROM ir_model_relation WHERE model IN %s + """, (tuple(model_ids),)) + print(f" Removed {cr.rowcount} relations") + + cr.execute(""" + DELETE FROM ir_model WHERE id IN %s + """, (tuple(model_ids),)) + print(f" Removed {cr.rowcount} model registry entries") + + # ===================================================================== + # Step 6: Remove Studio field on product.template that linked to demo pool + # ===================================================================== + print("[6/7] Removing demo pool Studio fields from product.template...") + for field_name in PRODUCT_TEMPLATE_STUDIO_FIELDS_TO_REMOVE: + cr.execute(""" + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'product_template' AND column_name = %s + ) + """, (field_name,)) + exists = cr.fetchone()[0] + if exists: + cr.execute(""" + DELETE FROM ir_model_fields_selection + WHERE field_id IN ( + SELECT id FROM ir_model_fields + WHERE model = 'product.template' AND name = %s + ) + """, (field_name,)) + + cr.execute(""" + DELETE FROM ir_model_fields + WHERE model = 'product.template' AND name = %s + """, (field_name,)) + + cr.execute(f'ALTER TABLE product_template DROP COLUMN IF EXISTS "{field_name}"') + print(f" Removed {field_name} from product.template") + else: + print(f" {field_name} not found on product.template (already removed)") + + # ===================================================================== + # Step 7: Drop the actual database tables + # ===================================================================== + print("[7/7] Dropping demo pool tables...") + for table in DEMO_POOL_TABLES: + cr.execute(f"DROP TABLE IF EXISTS \"{table}\" CASCADE") + print(f" Dropped {table}") + + cr.commit() + + print(f"\n{'='*60}") + print("Cleanup Complete") + print(f"{'='*60}") + print() + print("The following have been removed:") + print(" - x_demo_pool_tracking model and all line/stage/tag tables") + print(" - All related views, menus, actions, and access rules") + print(" - x_studio_many2one_field_V8rnk from product.template") + print() + print("NOT touched:") + print(" - x_studio_adp_price, x_studio_adp_sku on product.template") + print(" - x_studio_msrp, x_studio_product_description_long on product.template") + print(" - x_studio_product_details on product.template") + print() + print("Restart the Odoo server to clear the model registry cache.") + print(f"{'='*60}\n") + + +run_cleanup(env) diff --git a/fusion_claims/scripts/import_demo_pool.py b/fusion_claims/scripts/import_demo_pool.py new file mode 100644 index 0000000..ae31781 --- /dev/null +++ b/fusion_claims/scripts/import_demo_pool.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Demo Pool to Loaner Products Migration Script + +Reads all records from the Studio-created x_demo_pool_tracking table +and creates proper product.template records with x_fc_can_be_loaned=True, +plus stock.lot serial numbers where applicable. + +Run via Odoo shell: + docker exec -i odoo-mobility-app odoo shell -d mobility < import_demo_pool.py + +Or from the host with the script mounted: + docker exec -i odoo-mobility-app odoo shell -d mobility \ + < /mnt/extra-addons/fusion_claims/scripts/import_demo_pool.py + +Copyright 2024-2026 Nexa Systems Inc. +License OPL-1 (Odoo Proprietary License v1.0) +""" + +import json +import logging + +_logger = logging.getLogger(__name__) + +EQUIPMENT_TYPE_MAP = { + 'Type 1 Walker': 'type_1_walker', + 'Type 2 MW': 'type_2_mw', + 'Type 2 PW': 'type_2_pw', + 'Type 2 Walker': 'type_2_walker', + 'Type 3 MW': 'type_3_mw', + 'Type 3 PW': 'type_3_pw', + 'Type 3 Walker': 'type_3_walker', + 'Type 4 MW': 'type_4_mw', + 'Type 5 MW': 'type_5_mw', + 'Ceiling Lift': 'ceiling_lift', + 'Mobility Scooter': 'mobility_scooter', + 'Patient Lift': 'patient_lift', + 'Transport Wheelchair': 'transport_wheelchair', + 'Wheelchair': 'standard_wheelchair', + 'Standard Wheelchair': 'standard_wheelchair', + 'Power Wheelchair': 'power_wheelchair', + 'Cushion': 'cushion', + 'Backrest': 'backrest', + 'Stairlift': 'stairlift', + 'Others': 'others', +} + +WHEELCHAIR_CATEGORY_MAP = { + 'Type 1': 'type_1', + 'Type 2': 'type_2', + 'Type 3': 'type_3', + 'Type 4': 'type_4', + 'Type 5': 'type_5', +} + +LOCATION_MAP = { + 'Warehouse': 'warehouse', + 'Westin Brampton': 'westin_brampton', + 'Mobility Etobicoke': 'mobility_etobicoke', + 'Scarborough Storage': 'scarborough_storage', + 'Client/Loaned': 'client_loaned', + 'Rented Out': 'rented_out', +} + +LISTING_TYPE_MAP = { + 'Owned': 'owned', + 'Borrowed': 'borrowed', +} + +SKIP_SERIALS = {'na', 'n/a', 'update', 'updated', ''} + + +def extract_name(json_val): + """Extract English name from Odoo JSONB field.""" + if not json_val: + return '' + if isinstance(json_val, dict): + return json_val.get('en_US', '') or '' + if isinstance(json_val, str): + try: + parsed = json.loads(json_val) + if isinstance(parsed, dict): + return parsed.get('en_US', '') or '' + except (json.JSONDecodeError, TypeError): + return json_val + return str(json_val) + + +def get_category_id(env, equipment_type_key): + """Map equipment type to an appropriate product category.""" + loaner_cat = env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False) + wheelchair_cat = env.ref('fusion_claims.product_category_loaner_wheelchair', raise_if_not_found=False) + powerchair_cat = env.ref('fusion_claims.product_category_loaner_powerchair', raise_if_not_found=False) + rollator_cat = env.ref('fusion_claims.product_category_loaner_rollator', raise_if_not_found=False) + + if not loaner_cat: + return env['product.category'].search([], limit=1).id + + wheelchair_types = { + 'type_2_mw', 'type_3_mw', 'type_4_mw', 'type_5_mw', + 'type_2_walker', 'type_3_walker', 'type_1_walker', + 'standard_wheelchair', 'transport_wheelchair', + } + powerchair_types = {'type_2_pw', 'type_3_pw', 'power_wheelchair'} + rollator_types = set() + + if equipment_type_key in powerchair_types and powerchair_cat: + return powerchair_cat.id + if equipment_type_key in wheelchair_types and wheelchair_cat: + return wheelchair_cat.id + if equipment_type_key in rollator_types and rollator_cat: + return rollator_cat.id + return loaner_cat.id + + +def fetch_accessories(cr, demo_pool_id): + """Fetch accessory lines from x_demo_pool_tracking_line_b4ec9.""" + cr.execute(""" + SELECT x_name FROM x_demo_pool_tracking_line_b4ec9 + WHERE x_demo_pool_tracking_id = %s + ORDER BY x_studio_sequence, id + """, (demo_pool_id,)) + rows = cr.fetchall() + accessories = [] + for row in rows: + name = extract_name(row[0]) + if name: + accessories.append(name) + return accessories + + +def run_import(env): + cr = env.cr + ProductTemplate = env['product.template'] + StockLot = env['stock.lot'] + + company = env.company + + cr.execute(""" + SELECT + id, x_name, x_studio_equipment_type, x_studio_wheelchair_categorytype, + x_studio_serial_number, x_studio_seat_width, x_studio_seat_depth, + x_studio_seat_height, x_studio_where_is_it_located, x_studio_listing_type, + x_studio_asset_, x_studio_package_information, x_active, + x_studio_value, x_studio_notes + FROM x_demo_pool_tracking + ORDER BY id + """) + rows = cr.fetchall() + columns = [ + 'id', 'x_name', 'equipment_type', 'wheelchair_category', + 'serial_number', 'seat_width', 'seat_depth', + 'seat_height', 'location', 'listing_type', + 'asset_number', 'package_info', 'active', + 'value', 'notes', + ] + + created_count = 0 + serial_count = 0 + skipped_serials = 0 + errors = [] + + print(f"\n{'='*60}") + print("Demo Pool to Loaner Products Migration") + print(f"{'='*60}") + print(f"Records found: {len(rows)}") + print() + + for row in rows: + record = dict(zip(columns, row)) + demo_id = record['id'] + + try: + name = extract_name(record['x_name']) + if not name: + errors.append(f"ID {demo_id}: empty name, skipped") + continue + + equipment_type_raw = record['equipment_type'] or '' + equipment_type_key = EQUIPMENT_TYPE_MAP.get(equipment_type_raw, '') + + wheelchair_cat_raw = record['wheelchair_category'] or '' + wheelchair_cat_key = WHEELCHAIR_CATEGORY_MAP.get(wheelchair_cat_raw, '') + + location_raw = record['location'] or '' + location_key = LOCATION_MAP.get(location_raw, '') + + listing_raw = record['listing_type'] or '' + listing_key = LISTING_TYPE_MAP.get(listing_raw, '') + + seat_width = (record['seat_width'] or '').strip() + seat_depth = (record['seat_depth'] or '').strip() + seat_height = (record['seat_height'] or '').strip() + asset_number = (record['asset_number'] or '').strip() + package_info = (record['package_info'] or '').strip() + + accessories = fetch_accessories(cr, demo_id) + if accessories: + acc_text = '\n'.join(f'- {a}' for a in accessories) + if package_info: + package_info = f"{package_info}\n\nAccessories:\n{acc_text}" + else: + package_info = f"Accessories:\n{acc_text}" + + is_active = bool(record['active']) + categ_id = get_category_id(env, equipment_type_key) + + product_vals = { + 'name': name, + 'type': 'consu', + 'tracking': 'serial', + 'sale_ok': False, + 'purchase_ok': False, + 'x_fc_can_be_loaned': True, + 'x_fc_loaner_period_days': 7, + 'x_fc_equipment_type': equipment_type_key or False, + 'x_fc_wheelchair_category': wheelchair_cat_key or False, + 'x_fc_seat_width': seat_width or False, + 'x_fc_seat_depth': seat_depth or False, + 'x_fc_seat_height': seat_height or False, + 'x_fc_storage_location': location_key or False, + 'x_fc_listing_type': listing_key or False, + 'x_fc_asset_number': asset_number or False, + 'x_fc_package_info': package_info or False, + 'categ_id': categ_id, + 'active': is_active, + } + + product = ProductTemplate.with_context(active_test=False).create(product_vals) + created_count += 1 + + serial_raw = (record['serial_number'] or '').strip() + if serial_raw.lower() not in SKIP_SERIALS: + try: + product_product = product.product_variant_id + if product_product: + lot = StockLot.create({ + 'name': serial_raw, + 'product_id': product_product.id, + 'company_id': company.id, + }) + serial_count += 1 + except Exception as e: + skipped_serials += 1 + errors.append(f"ID {demo_id} ({name}): serial '{serial_raw}' failed: {e}") + else: + skipped_serials += 1 + + status = "ACTIVE" if is_active else "ARCHIVED" + print(f" [{status}] {name} (demo #{demo_id}) -> product #{product.id}" + f"{f' serial={serial_raw}' if serial_raw.lower() not in SKIP_SERIALS else ''}") + + except Exception as e: + errors.append(f"ID {demo_id}: {e}") + print(f" ERROR: ID {demo_id}: {e}") + + env.cr.commit() + + print(f"\n{'='*60}") + print("Migration Summary") + print(f"{'='*60}") + print(f"Products created: {created_count}") + print(f"Serials created: {serial_count}") + print(f"Serials skipped: {skipped_serials}") + print(f"Errors: {len(errors)}") + + if errors: + print(f"\nErrors:") + for err in errors: + print(f" - {err}") + + print(f"\nDone. Verify in Fusion Claims > Loaner Management > Loaner Products") + print(f"{'='*60}\n") + + +run_import(env) diff --git a/fusion_claims/static/src/xml/fusion_task_map_view.xml b/fusion_claims/static/src/xml/fusion_task_map_view.xml index a420046..f41cd92 100644 --- a/fusion_claims/static/src/xml/fusion_task_map_view.xml +++ b/fusion_claims/static/src/xml/fusion_task_map_view.xml @@ -154,14 +154,12 @@ Pins: diff --git a/fusion_claims/views/fusion_loaner_views.xml b/fusion_claims/views/fusion_loaner_views.xml index b6666b3..ab0937d 100644 --- a/fusion_claims/views/fusion_loaner_views.xml +++ b/fusion_claims/views/fusion_loaner_views.xml @@ -282,6 +282,60 @@ + + + + + + + product.template.loaner.list + product.template + + + + + + + + + + + + + + + + + + + product.template.loaner.search + product.template + + + + + + + + + + + + + + + + + + + @@ -336,6 +390,8 @@ Loaner Products product.template list,form + + [('x_fc_can_be_loaned', '=', True)] {'default_x_fc_can_be_loaned': True, 'default_sale_ok': False, 'default_purchase_ok': False, 'default_rent_ok': True} @@ -430,9 +486,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + diff --git a/fusion_claims/views/technician_task_views.xml b/fusion_claims/views/technician_task_views.xml index 47036ad..8e1228b 100644 --- a/fusion_claims/views/technician_task_views.xml +++ b/fusion_claims/views/technician_task_views.xml @@ -232,6 +232,22 @@ + + + + + + + + + + + + + + diff --git a/fusion_poynt/__init__.py b/fusion_poynt/__init__.py index 5e03a26..8607654 100644 --- a/fusion_poynt/__init__.py +++ b/fusion_poynt/__init__.py @@ -2,6 +2,7 @@ from . import controllers from . import models +from . import wizard def post_init_hook(env): diff --git a/fusion_poynt/__manifest__.py b/fusion_poynt/__manifest__.py index a322660..28ba207 100644 --- a/fusion_poynt/__manifest__.py +++ b/fusion_poynt/__manifest__.py @@ -7,15 +7,24 @@ 'sequence': 360, 'summary': "GoDaddy Poynt payment processing for cloud and terminal payments.", 'description': " ", - 'depends': ['payment', 'account_payment'], + 'depends': ['payment', 'account_payment', 'sale'], 'data': [ 'security/ir.model.access.csv', + 'report/poynt_receipt_report.xml', + 'report/poynt_receipt_templates.xml', + 'views/payment_provider_views.xml', + 'views/payment_transaction_views.xml', 'views/payment_poynt_templates.xml', 'views/poynt_terminal_views.xml', + 'views/account_move_views.xml', + 'views/sale_order_views.xml', + 'wizard/poynt_payment_wizard_views.xml', + 'wizard/poynt_refund_wizard_views.xml', 'data/payment_provider_data.xml', + 'data/poynt_receipt_email_template.xml', ], 'post_init_hook': 'post_init_hook', 'uninstall_hook': 'uninstall_hook', @@ -23,6 +32,10 @@ 'web.assets_frontend': [ 'fusion_poynt/static/src/interactions/**/*', ], + 'web.assets_backend': [ + 'fusion_poynt/static/src/js/**/*', + 'fusion_poynt/static/src/xml/**/*', + ], }, 'author': 'Fusion Apps', 'license': 'LGPL-3', diff --git a/fusion_poynt/data/payment_provider_data.xml b/fusion_poynt/data/payment_provider_data.xml index 40290ea..82f05a2 100644 --- a/fusion_poynt/data/payment_provider_data.xml +++ b/fusion_poynt/data/payment_provider_data.xml @@ -7,6 +7,7 @@ True disabled + diff --git a/fusion_poynt/data/poynt_receipt_email_template.xml b/fusion_poynt/data/poynt_receipt_email_template.xml new file mode 100644 index 0000000..50ce2b1 --- /dev/null +++ b/fusion_poynt/data/poynt_receipt_email_template.xml @@ -0,0 +1,97 @@ + + + + + + Poynt: Payment/Refund Receipt + + Refund Receipt
Payment Receipt {{ object.reference or 'n/a' }}]]> + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.email }} + + + +
+
+
+

+ +

+

+ Refund Receipt + Payment Receipt +

+

+ + Your refund for has been processed. + + + Your payment for has been processed successfully. + +

+ + + + + + + + + + + + + + + + + + + + + + + +
Transaction Details
Type + Refund + Payment +
Reference
Date
Status + Refunded + Confirmed +
Amount + - +
+ +
+

Attached: Transaction Receipt (PDF)

+
+ +
+

+ + The refund will appear on your card within 3-5 business days. If you have any questions, please do not hesitate to contact us. + + + Thank you for your payment. If you have any questions about this transaction, please do not hesitate to contact us. + +

+
+ + +

+ + | + +

+
+
+
+]]>
+ {{ object.partner_id.lang }} + + + + + diff --git a/fusion_poynt/models/__init__.py b/fusion_poynt/models/__init__.py index 105dc07..97adf45 100644 --- a/fusion_poynt/models/__init__.py +++ b/fusion_poynt/models/__init__.py @@ -1,6 +1,8 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import account_move from . import payment_provider from . import payment_token from . import payment_transaction from . import poynt_terminal +from . import sale_order diff --git a/fusion_poynt/models/account_move.py b/fusion_poynt/models/account_move.py new file mode 100644 index 0000000..4d8ed74 --- /dev/null +++ b/fusion_poynt/models/account_move.py @@ -0,0 +1,203 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class AccountMove(models.Model): + _inherit = 'account.move' + + poynt_refunded = fields.Boolean( + string="Refunded via Poynt", + readonly=True, + copy=False, + default=False, + ) + poynt_refund_count = fields.Integer( + string="Poynt Refund Count", + compute='_compute_poynt_refund_count', + ) + has_poynt_receipt = fields.Boolean( + string="Has Poynt Receipt", + compute='_compute_has_poynt_receipt', + ) + + @api.depends('reversal_move_ids') + def _compute_poynt_refund_count(self): + for move in self: + if move.move_type == 'out_invoice': + move.poynt_refund_count = len(move.reversal_move_ids.filtered( + lambda r: r.poynt_refunded + )) + else: + move.poynt_refund_count = 0 + + def _compute_has_poynt_receipt(self): + for move in self: + move.has_poynt_receipt = bool(move._get_poynt_transaction_for_receipt()) + + def action_view_poynt_refunds(self): + """Open the credit notes linked to this invoice that were refunded via Poynt.""" + self.ensure_one() + refund_moves = self.reversal_move_ids.filtered(lambda r: r.poynt_refunded) + action = { + 'name': _("Poynt Refunds"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'view_mode': 'list,form', + 'domain': [('id', 'in', refund_moves.ids)], + 'context': {'default_move_type': 'out_refund'}, + } + if len(refund_moves) == 1: + action['view_mode'] = 'form' + action['res_id'] = refund_moves.id + return action + + def _get_poynt_transaction_for_receipt(self): + """Find the Poynt transaction linked to this invoice or credit note.""" + self.ensure_one() + domain = [ + ('provider_id.code', '=', 'poynt'), + ('poynt_transaction_id', '!=', False), + ('state', '=', 'done'), + ] + if self.move_type == 'out_invoice': + domain.append(('invoice_ids', 'in', self.ids)) + elif self.move_type == 'out_refund': + domain += [ + ('operation', '=', 'refund'), + ('invoice_ids', 'in', self.ids), + ] + else: + return self.env['payment.transaction'] + + return self.env['payment.transaction'].sudo().search( + domain, order='id desc', limit=1, + ) + + def action_resend_poynt_receipt(self): + """Resend the Poynt payment/refund receipt email to the customer.""" + self.ensure_one() + tx = self._get_poynt_transaction_for_receipt() + if not tx: + raise UserError(_( + "No completed Poynt transaction found for this document." + )) + + template = self.env.ref( + 'fusion_poynt.mail_template_poynt_receipt', + raise_if_not_found=False, + ) + if not template: + raise UserError(_("Receipt email template not found.")) + + report = self.env.ref( + 'fusion_poynt.action_report_poynt_receipt', + raise_if_not_found=False, + ) + attachment_ids = [] + if report: + pdf_content, _content_type = report.sudo()._render_qweb_pdf( + report_ref='fusion_poynt.action_report_poynt_receipt', + res_ids=tx.ids, + ) + prefix = "Refund_Receipt" if self.move_type == 'out_refund' else "Payment_Receipt" + filename = f"{prefix}_{tx.reference}.pdf" + att = self.env['ir.attachment'].create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': self._name, + 'res_id': self.id, + 'mimetype': 'application/pdf', + }) + attachment_ids = [att.id] + + template.send_mail(tx.id, force_send=True) + + is_refund = self.move_type == 'out_refund' + label = _("Refund") if is_refund else _("Payment") + self.message_post( + body=_( + "%(label)s receipt resent to %(email)s.", + label=label, + email=tx.partner_id.email, + ), + message_type='notification', + subtype_xmlid='mail.mt_note', + attachment_ids=attachment_ids, + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _("Receipt Sent"), + 'message': _("The receipt has been sent to %s.", + tx.partner_id.email), + 'type': 'success', + 'sticky': False, + }, + } + + def action_open_poynt_payment_wizard(self): + """Open the Poynt payment collection wizard for this invoice.""" + self.ensure_one() + return { + 'name': _("Collect Poynt Payment"), + 'type': 'ir.actions.act_window', + 'res_model': 'poynt.payment.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': self.id, + }, + } + + def action_open_poynt_refund_wizard(self): + """Open the Poynt refund wizard for this credit note.""" + self.ensure_one() + return { + 'name': _("Refund via Poynt"), + 'type': 'ir.actions.act_window', + 'res_model': 'poynt.refund.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': self.id, + }, + } + + def _get_original_poynt_transaction(self): + """Find the Poynt payment transaction from the reversed invoice. + + For credit notes created via the "Reverse" action, the + ``reversed_entry_id`` links back to the original invoice. + We look for a confirmed Poynt transaction on that invoice. + """ + self.ensure_one() + origin_invoice = self.reversed_entry_id + if not origin_invoice: + return self.env['payment.transaction'] + + tx = self.env['payment.transaction'].sudo().search([ + ('invoice_ids', 'in', origin_invoice.ids), + ('state', '=', 'done'), + ('provider_id.code', '=', 'poynt'), + ('poynt_transaction_id', '!=', False), + ('poynt_voided', '=', False), + ], order='id desc', limit=1) + + if not tx: + tx = self.env['payment.transaction'].sudo().search([ + ('invoice_ids', 'in', origin_invoice.ids), + ('state', 'in', ('done', 'cancel')), + ('provider_id.code', '=', 'poynt'), + ('poynt_transaction_id', '!=', False), + ], order='id desc', limit=1) + + return tx diff --git a/fusion_poynt/models/payment_provider.py b/fusion_poynt/models/payment_provider.py index 1f9b88f..143b1ee 100644 --- a/fusion_poynt/models/payment_provider.py +++ b/fusion_poynt/models/payment_provider.py @@ -53,6 +53,13 @@ class PaymentProvider(models.Model): copy=False, groups='base.group_system', ) + poynt_default_terminal_id = fields.Many2one( + 'poynt.terminal', + string="Default Terminal", + help="The default Poynt terminal used for in-store payment collection. " + "Staff can override this per transaction.", + domain="[('provider_id', '=', id), ('active', '=', True)]", + ) # Cached access token fields (not visible in UI) _poynt_access_token = fields.Char( @@ -121,10 +128,16 @@ class PaymentProvider(models.Model): }, headers={ 'Content-Type': 'application/x-www-form-urlencoded', - 'Api-Version': const.API_VERSION, + 'api-version': const.API_VERSION, + 'Accept': 'application/json', }, timeout=30, ) + if response.status_code >= 400: + _logger.error( + "Poynt token request failed (HTTP %s): %s", + response.status_code, response.text[:1000], + ) response.raise_for_status() token_data = response.json() except requests.exceptions.RequestException as e: @@ -210,24 +223,32 @@ class PaymentProvider(models.Model): _("Poynt authentication expired. Please retry.") ) - if response.status_code == 204: + if response.status_code in (202, 204): return {} try: result = response.json() except ValueError: + if response.status_code < 400: + return {} _logger.error("Poynt returned non-JSON response: %s", response.text[:500]) raise ValidationError(_("Poynt returned an invalid response.")) if response.status_code >= 400: error_msg = result.get('message', result.get('developerMessage', 'Unknown error')) + dev_msg = result.get('developerMessage', '') _logger.error( - "Poynt API error %s: %s (request_id=%s)", + "Poynt API error %s: %s (request_id=%s)\n" + " URL: %s %s\n Payload: %s\n Response: %s\n Developer: %s", response.status_code, error_msg, request_id, + method, url, + json.dumps(payload)[:2000] if payload else 'None', + response.text[:2000], + dev_msg, ) raise ValidationError( _("Poynt API error (%(code)s): %(msg)s", - code=response.status_code, msg=error_msg) + code=response.status_code, msg=dev_msg or error_msg) ) return result @@ -252,6 +273,7 @@ class PaymentProvider(models.Model): minor_amount = poynt_utils.format_poynt_amount(amount, currency) if amount else 0 inline_form_values = { + 'provider_id': self.id, 'business_id': self.poynt_business_id, 'application_id': self.poynt_application_id, 'currency_name': currency.name if currency else 'USD', @@ -283,7 +305,11 @@ class PaymentProvider(models.Model): # === ACTION METHODS === # def action_poynt_test_connection(self): - """Test the connection to Poynt by fetching business info. + """Test the connection to Poynt by authenticating and fetching business info. + + If the Business ID appears to be a numeric MID rather than a UUID, + the method attempts to decode the access token to find the real + business UUID and auto-correct it. :return: A notification action with the result. :rtype: dict @@ -291,11 +317,25 @@ class PaymentProvider(models.Model): self.ensure_one() try: + access_token = self._poynt_get_access_token() + + business_id = self.poynt_business_id + is_uuid = business_id and '-' in business_id and len(business_id) > 30 + if not is_uuid and business_id: + resolved_biz_id = self._poynt_resolve_business_id(access_token) + if resolved_biz_id: + self.sudo().write({'poynt_business_id': resolved_biz_id}) + _logger.info( + "Auto-corrected Business ID from MID %s to UUID %s", + business_id, resolved_biz_id, + ) + result = self._poynt_make_request('GET', '') business_name = result.get('legalName', result.get('doingBusinessAs', 'Unknown')) message = _( - "Connection successful. Business: %(name)s", + "Connection successful. Business: %(name)s (ID: %(bid)s)", name=business_name, + bid=self.poynt_business_id, ) notification_type = 'success' except (ValidationError, UserError) as e: @@ -312,30 +352,71 @@ class PaymentProvider(models.Model): }, } + def _poynt_resolve_business_id(self, access_token): + """Try to extract the real business UUID from the access token JWT. + + The Poynt access token contains a 'poynt.biz' claim with the + merchant's business UUID when the token was obtained via merchant + authorization. For app-level tokens, we fall back to the 'poynt.org' + claim or attempt a direct API lookup. + + :param str access_token: The current access token. + :return: The business UUID, or False if it cannot be resolved. + :rtype: str or bool + """ + try: + import jwt as pyjwt + claims = pyjwt.decode(access_token, options={"verify_signature": False}) + biz_id = claims.get('poynt.biz') or claims.get('poynt.org') + if biz_id: + return biz_id + except Exception as e: + _logger.warning("Could not decode access token to find business ID: %s", e) + return False + def action_poynt_fetch_terminals(self): """Fetch terminal devices from Poynt and create/update local records. + Uses GET /businesses/{id}/stores which returns stores with their + nested storeDevices arrays. The main business endpoint does not + include stores in its response. + :return: A notification action with the result. :rtype: dict """ self.ensure_one() try: - store_id = self.poynt_store_id - if store_id: - endpoint = f'stores/{store_id}/storeDevices' - else: - endpoint = 'storeDevices' + result = self._poynt_make_request('GET', 'stores') + stores = result if isinstance(result, list) else result.get('stores', []) - result = self._poynt_make_request('GET', endpoint) - devices = result if isinstance(result, list) else result.get('storeDevices', []) + all_devices = [] + for store in stores: + store_id = store.get('id', '') + for device in store.get('storeDevices', []): + device['_store_id'] = store_id + device['_store_name'] = store.get('displayName', store.get('name', '')) + all_devices.append(device) + + if not all_devices: + return self._poynt_notification( + _("No terminal devices found for this business."), 'warning' + ) terminal_model = self.env['poynt.terminal'] created = 0 updated = 0 - for device in devices: + first_store_id = None + for device in all_devices: device_id = device.get('deviceId', '') + if not device_id: + continue + + store_id = device.get('_store_id', '') + if not first_store_id and store_id: + first_store_id = store_id + existing = terminal_model.search([ ('device_id', '=', device_id), ('provider_id', '=', self.id), @@ -347,7 +428,7 @@ class PaymentProvider(models.Model): 'serial_number': device.get('serialNumber', ''), 'provider_id': self.id, 'status': 'online' if device.get('status') == 'ACTIVATED' else 'offline', - 'store_id_poynt': device.get('storeId', ''), + 'store_id_poynt': store_id, } if existing: @@ -357,15 +438,53 @@ class PaymentProvider(models.Model): terminal_model.create(vals) created += 1 + if first_store_id and not self.poynt_store_id: + self.sudo().write({'poynt_store_id': first_store_id}) + _logger.info("Auto-filled Store ID: %s", first_store_id) + message = _( "Terminals synced: %(created)s created, %(updated)s updated.", created=created, updated=updated, ) - notification_type = 'success' + return self._poynt_notification(message, 'success') except (ValidationError, UserError) as e: - message = _("Failed to fetch terminals: %(error)s", error=str(e)) - notification_type = 'danger' + return self._poynt_notification( + _("Failed to fetch terminals: %(error)s", error=str(e)), 'danger' + ) + def _poynt_fetch_receipt(self, transaction_id): + """Fetch the rendered receipt from Poynt for a given transaction. + + Calls GET /businesses/{businessId}/transactions/{transactionId}/receipt + which returns a TransactionReceipt with a ``data`` field containing + the rendered receipt content (HTML or text). + + :param str transaction_id: The Poynt transaction UUID. + :return: The receipt content string, or None on failure. + :rtype: str | None + """ + self.ensure_one() + if not transaction_id: + return None + try: + result = self._poynt_make_request( + 'GET', f'transactions/{transaction_id}/receipt', + ) + return result.get('data') or None + except (ValidationError, Exception): + _logger.debug( + "Could not fetch Poynt receipt for transaction %s", transaction_id, + ) + return None + + def _poynt_notification(self, message, notification_type='info'): + """Return a display_notification action. + + :param str message: The notification message. + :param str notification_type: One of 'success', 'warning', 'danger', 'info'. + :return: The notification action dict. + :rtype: dict + """ return { 'type': 'ir.actions.client', 'tag': 'display_notification', diff --git a/fusion_poynt/models/payment_transaction.py b/fusion_poynt/models/payment_transaction.py index 4f18e67..6762204 100644 --- a/fusion_poynt/models/payment_transaction.py +++ b/fusion_poynt/models/payment_transaction.py @@ -1,5 +1,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 +import json import logging from werkzeug.urls import url_encode @@ -28,6 +30,23 @@ class PaymentTransaction(models.Model): readonly=True, copy=False, ) + poynt_receipt_data = fields.Text( + string="Poynt Receipt Data", + readonly=True, + copy=False, + help="JSON blob with receipt-relevant fields captured at payment time.", + ) + poynt_voided = fields.Boolean( + string="Voided on Poynt", + readonly=True, + copy=False, + default=False, + ) + poynt_void_date = fields.Datetime( + string="Voided On", + readonly=True, + copy=False, + ) # === BUSINESS METHODS - PAYMENT FLOW === # @@ -87,6 +106,8 @@ class PaymentTransaction(models.Model): try: order_payload = poynt_utils.build_order_payload( self.reference, self.amount, self.currency_id, + business_id=self.provider_id.poynt_business_id, + store_id=self.provider_id.poynt_store_id or '', ) order_result = self.provider_id._poynt_make_request( 'POST', 'orders', payload=order_payload, @@ -138,6 +159,8 @@ class PaymentTransaction(models.Model): order_payload = poynt_utils.build_order_payload( self.reference, self.amount, self.currency_id, + business_id=self.provider_id.poynt_business_id, + store_id=self.provider_id.poynt_store_id or '', ) order_result = self.provider_id._poynt_make_request( 'POST', 'orders', payload=order_payload, @@ -173,7 +196,12 @@ class PaymentTransaction(models.Model): self._set_error(str(e)) def _send_refund_request(self): - """Override of `payment` to send a refund request to Poynt.""" + """Override of `payment` to send a refund request to Poynt. + + For captured/settled transactions (SALE), we look up the + CAPTURE child via HATEOAS links and use that as ``parentId``. + The ``fundingSource`` is required per Poynt docs. + """ if self.provider_code != 'poynt': return super()._send_refund_request() @@ -181,10 +209,33 @@ class PaymentTransaction(models.Model): refund_amount = abs(self.amount) minor_amount = poynt_utils.format_poynt_amount(refund_amount, self.currency_id) + parent_txn_id = source_tx.poynt_transaction_id or source_tx.provider_reference + + try: + txn_data = self.provider_id._poynt_make_request( + 'GET', f'transactions/{parent_txn_id}', + ) + for link in txn_data.get('links', []): + if link.get('rel') == 'CAPTURE' and link.get('href'): + parent_txn_id = link['href'] + _logger.info( + "Refund: using captureId %s instead of original %s", + parent_txn_id, source_tx.poynt_transaction_id, + ) + break + except (ValidationError, Exception): + _logger.debug( + "Could not fetch parent txn %s, using original ID", + parent_txn_id, + ) + try: refund_payload = { 'action': 'REFUND', - 'parentId': source_tx.provider_reference, + 'parentId': parent_txn_id, + 'fundingSource': { + 'type': 'CREDIT_DEBIT', + }, 'amounts': { 'transactionAmount': minor_amount, 'orderAmount': minor_amount, @@ -250,35 +301,175 @@ class PaymentTransaction(models.Model): self._set_error(str(e)) def _send_void_request(self): - """Override of `payment` to send a void request to Poynt.""" + """Override of `payment` to send a void request to Poynt. + + Uses ``POST /transactions/{transactionId}/void`` -- the dedicated + void endpoint. + """ if self.provider_code != 'poynt': return super()._send_void_request() source_tx = self.source_transaction_id + txn_id = source_tx.provider_reference or source_tx.poynt_transaction_id try: - void_payload = { - 'action': 'VOID', - 'parentId': source_tx.provider_reference, - 'context': { - 'source': 'WEB', - 'sourceApp': 'odoo.fusion_poynt', - }, - } - result = self.provider_id._poynt_make_request( - 'POST', 'transactions', payload=void_payload, + 'POST', f'transactions/{txn_id}/void', ) payment_data = { 'reference': self.reference, - 'poynt_transaction_id': result.get('id'), + 'poynt_transaction_id': result.get('id', txn_id), 'poynt_status': result.get('status', 'VOIDED'), } self._process('poynt', payment_data) except ValidationError as e: self._set_error(str(e)) + # === ACTION METHODS - VOID === # + + def action_poynt_void(self): + """Void a confirmed Poynt transaction (same-day, before settlement). + + For SALE transactions Poynt creates an AUTHORIZE + CAPTURE pair. + Voiding the AUTHORIZE after capture is rejected by the processor, + so we first fetch the transaction, look for a CAPTURE child via + the HATEOAS ``links``, and void that instead. + + On success the linked ``account.payment`` is cancelled (reversing + invoice reconciliation) and the Odoo transaction is set to + cancelled. No credit note is created because funds were never + settled. + """ + self.ensure_one() + if self.provider_code != 'poynt': + raise ValidationError(_("This action is only available for Poynt transactions.")) + if self.state != 'done': + raise ValidationError(_("Only confirmed transactions can be voided.")) + txn_id = self.poynt_transaction_id or self.provider_reference + if not txn_id: + raise ValidationError(_("No Poynt transaction ID found.")) + + existing_refund = self.env['payment.transaction'].sudo().search([ + ('source_transaction_id', '=', self.id), + ('operation', '=', 'refund'), + ('state', '=', 'done'), + ], limit=1) + if existing_refund: + raise ValidationError(_( + "This transaction has already been refunded " + "(%(ref)s). A voided transaction and a refund would " + "result in a double reversal.", + ref=existing_refund.reference, + )) + + provider = self.sudo().provider_id + + txn_data = provider._poynt_make_request('GET', f'transactions/{txn_id}') + + poynt_status = txn_data.get('status', '') + if poynt_status in ('REFUNDED', 'VOIDED') or txn_data.get('voided'): + raise ValidationError(_( + "This transaction has already been %(status)s on Poynt. " + "It cannot be voided again.", + status=poynt_status.lower() if poynt_status else 'voided', + )) + + void_target_id = txn_id + + for link in txn_data.get('links', []): + child_id = link.get('href', '') + child_rel = link.get('rel', '') + if not child_id: + continue + if child_rel == 'CAPTURE': + void_target_id = child_id + try: + child_data = provider._poynt_make_request( + 'GET', f'transactions/{child_id}', + ) + child_status = child_data.get('status', '') + if child_status == 'REFUNDED' or child_data.get('voided'): + raise ValidationError(_( + "A linked transaction (%(child_id)s) has already " + "been %(status)s. Voiding would cause a double " + "reversal.", + child_id=child_id, + status='refunded' if child_status == 'REFUNDED' else 'voided', + )) + except ValidationError: + raise + except Exception: + continue + + _logger.info( + "Voiding Poynt transaction: original=%s, target=%s", + txn_id, void_target_id, + ) + + already_voided = txn_data.get('voided', False) + if already_voided: + _logger.info("Transaction %s is already voided on Poynt, skipping API call.", txn_id) + result = txn_data + else: + result = provider._poynt_make_request( + 'POST', f'transactions/{void_target_id}/void', + ) + + _logger.info( + "Poynt void response: status=%s, voided=%s, id=%s", + result.get('status'), result.get('voided'), result.get('id'), + ) + + is_voided = result.get('voided', False) + void_status = result.get('status', '') + + if not is_voided and void_status not in ('VOIDED', 'REFUNDED'): + if void_status == 'DECLINED': + raise ValidationError(_( + "Void declined by the payment processor. This usually " + "means the batch has already settled (past the daily " + "closeout at 6:00 PM). Settled transactions cannot be " + "voided.\n\n" + "To reverse this payment, create a Credit Note on the " + "invoice and process a refund through the standard " + "Odoo workflow." + )) + raise ValidationError( + _("Poynt did not confirm the void. Status: %(status)s", + status=void_status) + ) + + if self.payment_id: + self.payment_id.sudo().action_cancel() + + self.sudo().write({ + 'state': 'cancel', + 'poynt_voided': True, + 'poynt_void_date': fields.Datetime.now(), + }) + + invoice = self.invoice_ids[:1] + if invoice: + invoice.sudo().message_post( + body=_( + "Payment voided: transaction %(ref)s was voided on Poynt " + "(Poynt void ID: %(void_id)s).", + ref=self.reference, + void_id=result.get('id', ''), + ), + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'message': _("Transaction voided successfully on Poynt."), + 'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'}, + }, + } + # === BUSINESS METHODS - NOTIFICATION PROCESSING === # @api.model @@ -311,6 +502,16 @@ class PaymentTransaction(models.Model): return tx + def _extract_amount_data(self, payment_data): + """Override of `payment` to skip amount validation for Poynt. + + Terminal payments may include tips or rounding adjustments, so we + return None to opt out of the strict amount comparison. + """ + if self.provider_code != 'poynt': + return super()._extract_amount_data(payment_data) + return None + def _apply_updates(self, payment_data): """Override of `payment` to update the transaction based on Poynt data.""" if self.provider_code != 'poynt': @@ -347,13 +548,14 @@ class PaymentTransaction(models.Model): self._set_authorized() elif odoo_state == 'done': self._set_done() - if self.operation == 'refund': - self.env.ref('payment.cron_post_process_payment_tx')._trigger() + self._post_process() + self._poynt_generate_receipt(payment_data) elif odoo_state == 'cancel': self._set_canceled() elif odoo_state == 'refund': self._set_done() - self.env.ref('payment.cron_post_process_payment_tx')._trigger() + self._post_process() + self._poynt_generate_receipt(payment_data) elif odoo_state == 'error': error_msg = payment_data.get('error_message', _("Payment was declined by Poynt.")) self._set_error(error_msg) @@ -366,6 +568,67 @@ class PaymentTransaction(models.Model): _("Received data with unrecognized status: %s.", status) ) + def _create_payment(self, **extra_create_values): + """Override to route Poynt payments directly to the bank account. + + Card payments via Poynt are settled instantly -- there is no separate + bank reconciliation step. We swap the ``outstanding_account_id`` to + the journal's default (bank) account before posting so the payment + transitions to ``paid`` instead of lingering in ``in_process``. + """ + if self.provider_code != 'poynt': + return super()._create_payment(**extra_create_values) + + self.ensure_one() + reference = f'{self.reference} - {self.provider_reference or ""}' + payment_method_line = self.provider_id.journal_id.inbound_payment_method_line_ids\ + .filtered(lambda l: l.payment_provider_id == self.provider_id) + payment_values = { + 'amount': abs(self.amount), + 'payment_type': 'inbound' if self.amount > 0 else 'outbound', + 'currency_id': self.currency_id.id, + 'partner_id': self.partner_id.commercial_partner_id.id, + 'partner_type': 'customer', + 'journal_id': self.provider_id.journal_id.id, + 'company_id': self.provider_id.company_id.id, + 'payment_method_line_id': payment_method_line.id, + 'payment_token_id': self.token_id.id, + 'payment_transaction_id': self.id, + 'memo': reference, + 'write_off_line_vals': [], + 'invoice_ids': self.invoice_ids, + **extra_create_values, + } + + payment_term_lines = self.invoice_ids.line_ids.filtered( + lambda line: line.display_type == 'payment_term' + ) + if payment_term_lines: + payment_values['destination_account_id'] = payment_term_lines[0].account_id.id + + payment = self.env['account.payment'].create(payment_values) + + bank_account = self.provider_id.journal_id.default_account_id + if bank_account and bank_account.account_type == 'asset_cash': + payment.outstanding_account_id = bank_account + + payment.action_post() + self.payment_id = payment + + if self.operation == self.source_transaction_id.operation: + invoices = self.source_transaction_id.invoice_ids + else: + invoices = self.invoice_ids + invoices = invoices.filtered(lambda inv: inv.state != 'cancel') + if invoices: + invoices.filtered(lambda inv: inv.state == 'draft').action_post() + (payment.move_id.line_ids + invoices.line_ids).filtered( + lambda line: line.account_id == payment.destination_account_id + and not line.reconciled + ).reconcile() + + return payment + def _extract_token_values(self, payment_data): """Override of `payment` to return token data based on Poynt data.""" if self.provider_code != 'poynt': @@ -384,3 +647,163 @@ class PaymentTransaction(models.Model): 'payment_details': card_details.get('last4', ''), 'poynt_card_id': card_details.get('card_id', ''), } + + # === RECEIPT GENERATION === # + + def _poynt_generate_receipt(self, payment_data=None): + """Fetch transaction details from Poynt, store receipt data, generate + a PDF receipt, and attach it to the linked invoice's chatter. + + This method is best-effort: failures are logged but never block + the payment flow. + """ + self.ensure_one() + if self.provider_code != 'poynt' or not self.poynt_transaction_id: + return + + try: + self._poynt_store_receipt_data(payment_data) + self._poynt_attach_receipt_pdf() + self._poynt_attach_poynt_receipt() + except Exception: + _logger.exception( + "Receipt generation failed for transaction %s", self.reference, + ) + + def _poynt_store_receipt_data(self, payment_data=None): + """Fetch the full Poynt transaction and persist receipt-relevant + fields in :attr:`poynt_receipt_data` as a JSON blob.""" + txn_data = {} + try: + txn_data = self.provider_id._poynt_make_request( + 'GET', f'transactions/{self.poynt_transaction_id}', + ) + except (ValidationError, Exception): + _logger.debug( + "Could not fetch Poynt txn %s for receipt", self.poynt_transaction_id, + ) + + funding = payment_data.get('funding_source', {}) if payment_data else {} + if not funding: + funding = txn_data.get('fundingSource', {}) + + card = funding.get('card', {}) + entry = funding.get('entryDetails', {}) + amounts = txn_data.get('amounts', {}) + processor = txn_data.get('processorResponse', {}) + context = txn_data.get('context', {}) + + currency_name = amounts.get('currency', self.currency_id.name) + decimals = const.CURRENCY_DECIMALS.get(currency_name, 2) + + receipt = { + 'transaction_id': self.poynt_transaction_id, + 'order_id': self.poynt_order_id or '', + 'reference': self.reference, + 'status': txn_data.get('status', payment_data.get('poynt_status', '') if payment_data else ''), + 'created_at': txn_data.get('createdAt', ''), + 'card_type': card.get('type', ''), + 'card_last4': card.get('numberLast4', ''), + 'card_first6': card.get('numberFirst6', ''), + 'card_holder': card.get('cardHolderFullName', ''), + 'entry_mode': entry.get('entryMode', ''), + 'customer_presence': entry.get('customerPresenceStatus', ''), + 'transaction_amount': amounts.get('transactionAmount', 0) / (10 ** decimals) if amounts.get('transactionAmount') else float(self.amount), + 'tip_amount': amounts.get('tipAmount', 0) / (10 ** decimals) if amounts.get('tipAmount') else 0, + 'currency': currency_name, + 'approval_code': processor.get('approvalCode', txn_data.get('approvalCode', '')), + 'processor': processor.get('processor', ''), + 'processor_status': processor.get('status', ''), + 'store_id': context.get('storeId', ''), + 'device_id': context.get('storeDeviceId', ''), + } + + self.poynt_receipt_data = json.dumps(receipt) + + def _poynt_attach_receipt_pdf(self): + """Render the QWeb receipt report and attach the PDF to the invoice.""" + invoice = self.invoice_ids[:1] + if not invoice: + return + + try: + report = self.env.ref('fusion_poynt.action_report_poynt_receipt') + pdf_content, _report_type = report._render_qweb_pdf(report.report_name, [self.id]) + except Exception: + _logger.debug("Could not render Poynt receipt PDF for %s", self.reference) + return + + filename = f"Payment_Receipt_{self.reference}.pdf" + attachment = self.env['ir.attachment'].sudo().create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': 'account.move', + 'res_id': invoice.id, + 'mimetype': 'application/pdf', + }) + + invoice.sudo().message_post( + body=_( + "Payment receipt generated for transaction %(ref)s.", + ref=self.reference, + ), + attachment_ids=[attachment.id], + ) + + def _poynt_attach_poynt_receipt(self): + """Try the Poynt renderReceipt endpoint and attach the result.""" + invoice = self.invoice_ids[:1] + if not invoice: + return + + receipt_content = self.provider_id._poynt_fetch_receipt( + self.poynt_transaction_id, + ) + if not receipt_content: + return + + filename = f"Poynt_Receipt_{self.reference}.html" + self.env['ir.attachment'].sudo().create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(receipt_content.encode('utf-8')), + 'res_model': 'account.move', + 'res_id': invoice.id, + 'mimetype': 'text/html', + }) + + def _get_poynt_receipt_values(self): + """Parse the stored receipt JSON for use in QWeb templates. + + For refund transactions that lack their own receipt data, this + falls back to the source (sale) transaction's receipt data so the + card details and approval codes are still available. + + :return: Dict of receipt values or empty dict. + :rtype: dict + """ + self.ensure_one() + data = self.poynt_receipt_data + if not data and self.source_transaction_id: + data = self.source_transaction_id.poynt_receipt_data + if not data: + return {} + try: + return json.loads(data) + except (json.JSONDecodeError, TypeError): + return {} + + def _get_source_receipt_values(self): + """Return receipt values from the original sale transaction. + + Used by the refund receipt template to render the original sale + details on a second page. + """ + self.ensure_one() + if self.source_transaction_id and self.source_transaction_id.poynt_receipt_data: + try: + return json.loads(self.source_transaction_id.poynt_receipt_data) + except (json.JSONDecodeError, TypeError): + pass + return {} diff --git a/fusion_poynt/models/poynt_terminal.py b/fusion_poynt/models/poynt_terminal.py index 35bc2b1..de291e4 100644 --- a/fusion_poynt/models/poynt_terminal.py +++ b/fusion_poynt/models/poynt_terminal.py @@ -1,5 +1,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +import json import logging from odoo import _, api, fields, models @@ -129,22 +130,29 @@ class PoyntTerminal(models.Model): if order_id: payment_request['orderId'] = order_id + store_id = self.store_id_poynt or self.provider_id.poynt_store_id or '' + + data_str = json.dumps({ + 'action': 'sale', + 'purchaseAmount': minor_amount, + 'tipAmount': 0, + 'currency': currency.name, + 'referenceId': reference, + 'callbackUrl': self._get_terminal_callback_url(), + }) + try: result = self.provider_id._poynt_make_request( 'POST', - f'cloudMessages', + 'cloudMessages', + business_scoped=False, payload={ + 'businessId': self.provider_id.poynt_business_id, + 'storeId': store_id, 'deviceId': self.device_id, 'ttl': 300, 'serialNum': self.serial_number or '', - 'data': { - 'action': 'sale', - 'purchaseAmount': minor_amount, - 'tipAmount': 0, - 'currency': currency.name, - 'referenceId': reference, - 'callbackUrl': self._get_terminal_callback_url(), - }, + 'data': data_str, }, ) _logger.info( @@ -171,6 +179,9 @@ class PoyntTerminal(models.Model): def action_check_terminal_payment_status(self, reference): """Poll for the status of a terminal payment. + Searches Poynt transactions by referenceId (set via cloud message) + and falls back to notes field. + :param str reference: The Odoo transaction reference. :return: Dict with status and transaction data if completed. :rtype: dict @@ -182,15 +193,37 @@ class PoyntTerminal(models.Model): 'GET', 'transactions', params={ - 'notes': reference, - 'limit': 1, + 'referenceId': reference, + 'limit': 5, }, ) transactions = txn_result.get('transactions', []) + + if not transactions: + txn_result = self.provider_id._poynt_make_request( + 'GET', + 'transactions', + params={ + 'notes': reference, + 'limit': 5, + }, + ) + transactions = txn_result.get('transactions', []) + if not transactions: return {'status': 'pending', 'message': 'Waiting for terminal response...'} + for txn in transactions: + status = txn.get('status', 'UNKNOWN') + if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED'): + return { + 'status': status, + 'transaction_id': txn.get('id', ''), + 'funding_source': txn.get('fundingSource', {}), + 'amounts': txn.get('amounts', {}), + } + txn = transactions[0] return { 'status': txn.get('status', 'UNKNOWN'), diff --git a/fusion_poynt/models/sale_order.py b/fusion_poynt/models/sale_order.py new file mode 100644 index 0000000..4f7b286 --- /dev/null +++ b/fusion_poynt/models/sale_order.py @@ -0,0 +1,60 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import _, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def action_poynt_collect_payment(self): + """Create an invoice (if needed) and open the Poynt payment wizard. + + This shortcut lets staff collect payment directly from a confirmed + sale order without manually creating and posting the invoice first. + """ + self.ensure_one() + + if self.state not in ('sale', 'done'): + raise UserError( + _("You can only collect payment on confirmed orders.") + ) + + invoice = self.invoice_ids.filtered( + lambda inv: inv.state == 'posted' + and inv.payment_state in ('not_paid', 'partial') + and inv.move_type == 'out_invoice' + )[:1] + + if not invoice: + draft_invoices = self.invoice_ids.filtered( + lambda inv: inv.state == 'draft' + and inv.move_type == 'out_invoice' + ) + if draft_invoices: + invoice = draft_invoices[0] + invoice.action_post() + else: + invoices = self._create_invoices() + if not invoices: + raise UserError( + _("Could not create an invoice for this order.") + ) + invoice = invoices[0] + invoice.action_post() + + return { + 'name': _("Collect Poynt Payment"), + 'type': 'ir.actions.act_window', + 'res_model': 'poynt.payment.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': invoice.id, + }, + } diff --git a/fusion_poynt/report/poynt_receipt_report.xml b/fusion_poynt/report/poynt_receipt_report.xml new file mode 100644 index 0000000..620d949 --- /dev/null +++ b/fusion_poynt/report/poynt_receipt_report.xml @@ -0,0 +1,14 @@ + + + + + Poynt Payment Receipt + payment.transaction + qweb-pdf + fusion_poynt.report_poynt_receipt_document + fusion_poynt.report_poynt_receipt_document + 'Payment_Receipt_%s' % object.reference + report + + + diff --git a/fusion_poynt/report/poynt_receipt_templates.xml b/fusion_poynt/report/poynt_receipt_templates.xml new file mode 100644 index 0000000..dc76fdb --- /dev/null +++ b/fusion_poynt/report/poynt_receipt_templates.xml @@ -0,0 +1,303 @@ + + + + + + diff --git a/fusion_poynt/security/ir.model.access.csv b/fusion_poynt/security/ir.model.access.csv index 0f7529a..f8d5929 100644 --- a/fusion_poynt/security/ir.model.access.csv +++ b/fusion_poynt/security/ir.model.access.csv @@ -1,3 +1,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_poynt_terminal_user,poynt.terminal.user,model_poynt_terminal,base.group_user,1,0,0,0 access_poynt_terminal_admin,poynt.terminal.admin,model_poynt_terminal,base.group_system,1,1,1,1 +access_poynt_payment_wizard_user,poynt.payment.wizard.user,model_poynt_payment_wizard,account.group_account_invoice,1,1,1,0 +access_poynt_payment_wizard_admin,poynt.payment.wizard.admin,model_poynt_payment_wizard,base.group_system,1,1,1,1 +access_poynt_refund_wizard_user,poynt.refund.wizard.user,model_poynt_refund_wizard,account.group_account_invoice,1,1,1,0 +access_poynt_refund_wizard_admin,poynt.refund.wizard.admin,model_poynt_refund_wizard,base.group_system,1,1,1,1 diff --git a/fusion_poynt/static/src/img/poynt_logo.png b/fusion_poynt/static/src/img/poynt_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6157b7739eaf4cb2eb0a1d28891d7aabf5051bd3 GIT binary patch literal 37613 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 literal 0 HcmV?d00001 diff --git a/fusion_poynt/static/src/js/poynt_wizard_poll.js b/fusion_poynt/static/src/js/poynt_wizard_poll.js new file mode 100644 index 0000000..c16b7c5 --- /dev/null +++ b/fusion_poynt/static/src/js/poynt_wizard_poll.js @@ -0,0 +1,69 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Component, onMounted, onWillUnmount, useState } from "@odoo/owl"; + +class PoyntPollAction extends Component { + static template = "fusion_poynt.PoyntPollAction"; + static props = ["*"]; + + setup() { + this.orm = useService("orm"); + this.actionService = useService("action"); + this.state = useState({ seconds: 0, message: "" }); + + const wizardId = this.props.action?.params?.wizard_id; + this.wizardId = wizardId; + + onMounted(() => { + if (!this.wizardId) return; + this._tickTimer = setInterval(() => this.state.seconds++, 1000); + this._pollTimer = setInterval(() => this._poll(), 5000); + }); + + onWillUnmount(() => this._stop()); + } + + _stop() { + if (this._tickTimer) { clearInterval(this._tickTimer); this._tickTimer = null; } + if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; } + } + + async _poll() { + if (!this.wizardId) { this._stop(); return; } + try { + const result = await this.orm.call( + "poynt.payment.wizard", "action_check_status", [[this.wizardId]], + ); + if (result && result.type) { + this._stop(); + this.actionService.doAction(result, { clearBreadcrumbs: true }); + } + } catch (e) { + this._stop(); + this.state.message = "Polling stopped due to an error. Use Check Now to retry."; + } + } + + async onManualCheck() { + this.state.message = ""; + if (!this._pollTimer) { + this._pollTimer = setInterval(() => this._poll(), 5000); + this._tickTimer = setInterval(() => this.state.seconds++, 1000); + } + await this._poll(); + } + + async onCancel() { + this._stop(); + try { + await this.orm.call( + "poynt.payment.wizard", "action_cancel_payment", [[this.wizardId]], + ); + } catch { /* ignore */ } + this.actionService.doAction({ type: "ir.actions.act_window_close" }); + } +} + +registry.category("actions").add("poynt_poll_action", PoyntPollAction); diff --git a/fusion_poynt/static/src/xml/poynt_wizard_poll.xml b/fusion_poynt/static/src/xml/poynt_wizard_poll.xml new file mode 100644 index 0000000..53f7f7c --- /dev/null +++ b/fusion_poynt/static/src/xml/poynt_wizard_poll.xml @@ -0,0 +1,27 @@ + + + + +
+
+ +
+ Waiting for terminal response... +
+ Auto-checking every 5 seconds + (s elapsed) +
+
+
+
+ + +
+
+
+ +
diff --git a/fusion_poynt/utils.py b/fusion_poynt/utils.py index 4d57f65..c7c7317 100644 --- a/fusion_poynt/utils.py +++ b/fusion_poynt/utils.py @@ -41,16 +41,37 @@ def build_api_headers(access_token, request_id=None): :return: The request headers dict. :rtype: dict """ + if not request_id: + request_id = generate_request_id() headers = { 'Content-Type': 'application/json', - 'Api-Version': const.API_VERSION, + 'api-version': const.API_VERSION, + 'Accept': 'application/json', 'Authorization': f'Bearer {access_token}', + 'Poynt-Request-Id': request_id, } - if request_id: - headers['POYNT-REQUEST-ID'] = request_id return headers +def clean_application_id(raw_app_id): + """Extract the urn:aid:... portion from a raw application ID string. + + Poynt developer portal sometimes displays the app UUID and URN together + (e.g. 'a73a2957-...=urn:aid:fb0ba879-...'). The JWT needs only the URN. + + :param str raw_app_id: The raw application ID string. + :return: The cleaned application ID (urn:aid:...). + :rtype: str + """ + if not raw_app_id: + return raw_app_id + raw_app_id = raw_app_id.strip() + if 'urn:aid:' in raw_app_id: + idx = raw_app_id.index('urn:aid:') + return raw_app_id[idx:] + return raw_app_id + + def create_self_signed_jwt(application_id, private_key_pem): """Create a self-signed JWT for Poynt OAuth2 token request. @@ -81,10 +102,12 @@ def create_self_signed_jwt(application_id, private_key_pem): private_key = load_pem_private_key(key_bytes, password=None, backend=default_backend()) + app_id = clean_application_id(application_id) + now = int(time.time()) payload = { - 'iss': application_id, - 'sub': application_id, + 'iss': app_id, + 'sub': app_id, 'aud': 'https://services.poynt.net', 'iat': now, 'exp': now + 300, @@ -162,12 +185,15 @@ def get_poynt_status(status_str): return 'error' -def build_order_payload(reference, amount, currency, items=None, notes=''): +def build_order_payload(reference, amount, currency, business_id='', + store_id='', items=None, notes=''): """Build a Poynt order creation payload. :param str reference: The Odoo transaction reference. :param float amount: The order total in major currency units. :param recordset currency: The currency record. + :param str business_id: The Poynt business UUID. + :param str store_id: The Poynt store UUID. :param list items: Optional list of order item dicts. :param str notes: Optional order notes. :return: The Poynt-formatted order payload. @@ -185,6 +211,15 @@ def build_order_payload(reference, amount, currency, items=None, notes=''): 'unitOfMeasure': 'EACH', }] + context = { + 'source': 'WEB', + 'sourceApp': 'odoo.fusion_poynt', + } + if business_id: + context['businessId'] = business_id + if store_id: + context['storeId'] = store_id + return { 'items': items, 'amounts': { @@ -195,10 +230,7 @@ def build_order_payload(reference, amount, currency, items=None, notes=''): 'netTotal': minor_amount, 'currency': currency.name, }, - 'context': { - 'source': 'WEB', - 'transactionInstruction': 'ONLINE_AUTH_REQUIRED', - }, + 'context': context, 'statuses': { 'status': 'OPENED', }, diff --git a/fusion_poynt/views/account_move_views.xml b/fusion_poynt/views/account_move_views.xml new file mode 100644 index 0000000..3c6e0b1 --- /dev/null +++ b/fusion_poynt/views/account_move_views.xml @@ -0,0 +1,83 @@ + + + + + account.move.form.poynt.button + account.move + + 60 + + + + + + + + + +