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 0000000..6157b77 Binary files /dev/null and b/fusion_poynt/static/src/img/poynt_logo.png differ 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 + + + + + + + + + +