How to Build a PrestaShop Module for Inline Editing of Payment Records on the Order Page?

How to Build a PrestaShop Module for Inline Editing of Payment Records on the Order Page?

This guide shows a high-level plan and step-by-step implementation to build a PrestaShop module that enables inline editing of payment records directly on the order page.


Step 1: Generate the Module Skeleton

Start by generating the module skeleton at:
https://validator.prestashop.com


Step 2: Create the Required Files in Appropriate Folders

Organize your module files as follows:

ps_orderpaymentedit/
├── ps_orderpaymentedit.php
├── controllers/
│   └── admin/
│       └── AdminAjaxPaymentEditController.php  <——
├── views/
│   └── js/
│       ├── crypto-js.min.js   <—— optional (download from https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js)
│       └── payment-edit.js   <——
└── ps_orderpaymentedit.py  <——

Step 3: Custom Logic in ps_orderpaymentedit.php

<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License 3.0 (AFL-3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to [email protected] so we can send you a copy immediately.
*
* @author Genkiware Limited
* @copyright Since 2025 Genkiware Limited
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License 3.0 (AFL-3.0)
*/
if (!defined('_PS_VERSION_')) {
    exit;
}

class Ps_OrderPaymentEdit extends Module
{
    public function __construct()
    {
        $this->name = 'ps_orderpaymentedit';
        $this->author = 'Genkiware Ltd';
        $this->version = '1.0.0';
        $this->tab = 'administration';
        $this->need_instance = 0;
        parent::__construct();
        $this->displayName = $this->l('Editable Order Payment Records');
        $this->description = $this->l('Allow inline editing of payment records in the order page.');
        $this->ps_versions_compliancy = ['min' => '1.7', 'max' => _PS_VERSION_];
    }

    public function install()
    {
        return parent::install() &&
            $this->registerHook('displayAdminOrder') &&
            $this->registerHook('actionAdminControllerSetMedia');
    }

    public function hookActionAdminControllerSetMedia()
    {
        $controller = Tools::getValue('controller');
        if ($controller == 'AdminOrders') {
            $this->context->controller->addJS($this->_path . 'views/js/payment-edit.js');
        }
    }

    public function hookDisplayAdminOrder($params)
    {
        $orderId = (int)$params['id_order'];
        try {
            $order = new Order($orderId);

            if (!Validate::isLoadedObject($order)) {
                return "";
            }

            $payments = $order->getOrderPayments();
            $paymentData = [];
            foreach ($payments as $payment) {
                $paymentData[] = [
                    'id' => $payment->id,
                    'raw_date' => $payment->date_add,
                    'id_order' => isset($payment->id_order) ? (int)$payment->id_order : (int)$orderId,
                ];
            }

            $token = Tools::getAdminTokenLite('AdminAjaxPaymentEdit');
            $url = $this->context->link->getAdminLink('AdminAjaxPaymentEdit');

            return "
            <script>
            window.psEditPaymentData = {
                url: '$url',
                token: '$token',
                payments: " . json_encode($paymentData) . "
            };
            </script>
            ";
        } catch (Exception $e) {
            return "";
        }
    }
}

Step 4: Custom Logic in payment-edit.js

/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to [email protected] so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author Genkiware Limited
* @copyright Since 2025 Genkiware Limited
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License 3.0 (AFL-3.0)
*/
document.addEventListener('DOMContentLoaded', function () {
    // Debug flag
    const DEBUG = true;

    function log(...args) {
        if (DEBUG) console.log('[EditPayments]', ...args);
    }

    function warn(...args) {
        console.warn('[EditPayments]', ...args);
    }

    function error(...args) {
        console.error('[EditPayments]', ...args);
    }

    // Sanitize input values
    function sanitizeInput(value) {
        // Remove any parentheses and trim whitespace
        return value.replace(/[()]/g, '').trim();
    }

    // Main function
    function initPaymentEdit() {
        const paymentBlock = document.querySelector('#view_order_payments_block');
        if (!paymentBlock) {
            warn('#view_order_payments_block not found');
            return;
        }
        const paymentTable = paymentBlock.querySelector('table');
        if (!paymentTable) {
            warn('Payment table not found inside block');
            return;
        }
        const rows = paymentTable.querySelectorAll('tbody tr');
        log(`Found ${rows.length} rows`);

        // Get payment rows (exclude form rows)
        const paymentRows = Array.from(rows).filter(row =>
            !row.querySelector('input, select') &&
            row.querySelectorAll('td').length >= 4
        );

        // Check if we have payment data
        if (!window.psEditPaymentData) {
            warn('window.psEditPaymentData not defined');
            return;
        }

        // Assign payment IDs to rows
        if (window.psEditPaymentData.payments) {
            paymentRows.forEach((row, index) => {
                if (index < window.psEditPaymentData.payments.length) {
                    const payment = window.psEditPaymentData.payments[index];
                    row.dataset.paymentId = payment.id;
                    row.dataset.rawDate = payment.raw_date;
                    log(`Assigned payment ID ${payment.id} to row ${index}`);
                } else {
                    warn(`No payment data for row index ${index}`);
                }
            });
        } else {
            warn('No payments data in psEditPaymentData');
        }

        paymentRows.forEach(row => {
            const lastTd = row.querySelector('td:last-child');
            if (!lastTd || lastTd.querySelector('.edit-payment-btn')) return;

            // Create Edit button
            const editBtn = createEditButton(row);
            lastTd.appendChild(editBtn);
        });
    }

    function createEditButton(row) {
        const editBtn = document.createElement('button');
        editBtn.className = 'btn btn-sm btn-outline-primary edit-payment-btn ml-1';
        editBtn.innerHTML = '<i class="material-icons">edit</i>';
        editBtn.title = 'Edit payment';
        editBtn.addEventListener('click', () => {
            editBtn.disabled = true;
            startEditing(row, editBtn);
        });
        return editBtn;
    }

    function startEditing(row, editBtn) {
        const tds = row.querySelectorAll('td:not(:last-child)');
        const originalValues = [];
        tds.forEach((td, i) => {
            const original = td.textContent.trim();
            originalValues.push(original);
            let input = createInputForCell(td, i, row);
            td.innerHTML = "";
            td.appendChild(input);
        });

        const actionTd = row.querySelector('td:last-child');
        actionTd.innerHTML = "";

        const saveBtn = createActionButton('btn-success', 'Save', () => {
            saveChanges(row, tds, originalValues, actionTd, editBtn);
        });
        const cancelBtn = createActionButton('btn-secondary', 'Cancel', () => {
            cancelEditing(row, tds, originalValues, actionTd, editBtn);
        });
        actionTd.appendChild(saveBtn);
        actionTd.appendChild(cancelBtn);
    }

    function createInputForCell(td, index, row) {
        const original = td.textContent.trim();
        const input = document.createElement('input');
        input.className = 'form-control form-control-sm';

        if (index === 0 && row.dataset.rawDate) {
            input.type = 'datetime-local';
            input.step = '1';
            input.value = row.dataset.rawDate.replace(' ', 'T');
        } else if (index === 3) {
            input.type = 'number';
            input.step = '0.01';
            input.value = parseFloat(original.replace(/[^\d.]/g, '')) || 0;
        } else {
            input.type = 'text';
            input.value = original;
        }
        return input;
    }

    function createActionButton(className, text, onClick) {
        const btn = document.createElement('button');
        btn.className = `btn ${className} btn-sm mr-1`;
        btn.textContent = text;
        btn.addEventListener('click', onClick);
        return btn;
    }

    function saveChanges(row, tds, originalValues, actionTd, editBtn) {
        const inputs = row.querySelectorAll('td input');
        let newValues = Array.from(inputs).map(input => sanitizeInput(input.value));

        // Convert datetime-local back to standard format
        if (inputs[0].type === 'datetime-local') {
            newValues[0] = newValues[0].replace('T', ' ');
        }

        const paymentId = row.dataset.paymentId;
        if (!paymentId) {
            error('Missing payment ID');
            alert('Payment ID not found. Cannot save.');
            return;
        }

        // Validate inputs client-side
        const errors = validateInputs(newValues);
        if (errors.length > 0) {
            alert(`Validation errors:\n${errors.join('\n')}`);
            return;
        }

        // Prepare request data
        const formData = new URLSearchParams();
        formData.append('action', 'editPayment');
        formData.append('ajax', 1);
        formData.append('id_order_payment', paymentId);
        formData.append('date', newValues[0]);
        formData.append('payment_method', newValues[1]);
        formData.append('transaction_id', newValues[2]);
        formData.append('amount', newValues[3]);
        formData.append('token', window.psEditPaymentData.token);

        // Disable all buttons
        setButtonsDisabled(true);
        log('Saving payment ID:', paymentId, 'Data:', newValues);

        fetch(window.psEditPaymentData.url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: formData
        })
        .then(response => handleResponse(response))
        .then(data => handleSaveResponse(data))
        .catch(err => handleSaveError(err));

        function handleResponse(response) {
            if (!response.ok) {
                return response.text().then(text => {
                    throw new Error(`HTTP Error ${response.status}: ${text.substring(0, 200)}`);
                });
            }
            return response.json();
        }

        function handleSaveResponse(data) {
            if (data && data.success) {
                log('Save successful');
                completeSave(row, tds, originalValues, newValues, actionTd, editBtn);
            } else {
                throw new Error(data.error || 'Save failed without error message');
            }
        }

        function handleSaveError(err) {
            // Extract meaningful error message
            let errorMsg = err.message;
            if (errorMsg.includes('<!DOCTYPE') || errorMsg.includes('<html')) {
                errorMsg = 'Server error: Check console for details';
            }
            error('Save error:', err);
            alert(`Error: ${errorMsg}`);
            setButtonsDisabled(false);
        }

        function setButtonsDisabled(disabled) {
            const buttons = actionTd.querySelectorAll('button');
            buttons.forEach(btn => btn.disabled = disabled);
            editBtn.disabled = disabled;
        }
    }

    function validateInputs(values) {
        const errors = [];

        // Date validation
        if (!values[0] || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(values[0])) {
            errors.push('- Invalid date format (expected YYYY-MM-DD HH:MM:SS)');
        }

        // Payment method validation
        if (!values[1]) {
            errors.push('- Payment method cannot be empty');
        }

        // Transaction ID validation
        if (!values[2]) {
            errors.push('- Transaction ID cannot be empty');
        }

        // Amount validation
        if (!values[3] || isNaN(values[3]) || parseFloat(values[3]) <= 0) {
            errors.push('- Amount must be a positive number');
        }

        return errors;
    }

    function completeSave(row, tds, originalValues, newValues, actionTd, editBtn) {
        tds.forEach((td, i) => {
            // For date, use the original formatted value
            if (i === 0) {
                td.innerHTML = originalValues[0];
            } else {
                td.innerHTML = newValues[i];
            }
        });

        // Update raw date for future edits
        row.dataset.rawDate = newValues[0];

        // Reset buttons
        actionTd.innerHTML = "";
        actionTd.appendChild(editBtn);
        editBtn.disabled = false;
    }

    function cancelEditing(row, tds, originalValues, actionTd, editBtn) {
        tds.forEach((td, i) => {
            td.innerHTML = originalValues[i];
        });
        actionTd.innerHTML = "";
        actionTd.appendChild(editBtn);
        editBtn.disabled = false;
    }

    // Initialize the module
    initPaymentEdit();
});

Step 5: Custom Logic in AdminAjaxPaymentEditController.php

<?php
/**
 * Copyright since 2007 PrestaShop SA and Contributors
 * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Academic Free License 3.0 (AFL-3.0)
 * that is bundled with this package in the file LICENSE.md.
 * It is also available through the world-wide-web at this URL:
 * https://opensource.org/licenses/AFL-3.0
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to [email protected] so we can send you a copy immediately.
 *
 * @author Genkiware Limited
 * @copyright Since 2025 Genkiware Limited
 * @license https://opensource.org/licenses/AFL-3.0 Academic Free License 3.0 (AFL-3.0)
 */

if (!defined('_PS_VERSION_')) {
    exit;
}

class AdminAjaxPaymentEditController extends ModuleAdminController
{
    public function init()
    {
        parent::init();

        // Clean output buffers
        while (ob_get_level()) {
            ob_end_clean();
        }

        // Process AJAX request
        if (Tools::getIsset('ajax') && Tools::getValue('action') === 'editPayment') {
            $this->processAjax();
        }

        exit;
    }

    protected function processAjax()
    {
        try {
            // Verify token
            $token = Tools::getValue('token');
            $expectedToken = Tools::getAdminTokenLite('AdminAjaxPaymentEdit');

            if (!$token || $token !== $expectedToken) {
                throw new Exception('Invalid security token');
            }

            $id_order_payment = (int) Tools::getValue('id_order_payment');
            if ($id_order_payment <= 0) {
                throw new Exception('Invalid payment ID');
            }

            $payment = new OrderPayment($id_order_payment);
            if (!Validate::isLoadedObject($payment)) {
                throw new Exception('Payment not found with ID: ' . $id_order_payment);
            }

            $data = $this->validateInputs();

            // Update payment and related order
            $this->updatePayment($payment, $data);

            $this->sendJsonResponse(['success' => true]);
        } catch (Exception $e) {
            $this->sendJsonResponse([
                'success' => false,
                'error' => $e->getMessage()
            ]);
        }
    }

    protected function validateInputs()
    {
        $errors = [];

        $data = [
            'date'           => trim(Tools::getValue('date')),
            'amount'         => trim(Tools::getValue('amount')),
            'payment_method' => trim(Tools::getValue('payment_method')),
            'transaction_id' => trim(Tools::getValue('transaction_id')),
        ];

        // Sanitize strings
        $data['payment_method'] = $this->sanitizeString($data['payment_method']);
        $data['transaction_id'] = $this->sanitizeString($data['transaction_id']);

        // Date validation
        if (empty($data['date']) || !Validate::isDate($data['date']) || !$this->isValidDateTime($data['date'])) {
            $errors[] = 'Invalid date format (expected YYYY-MM-DD HH:MM:SS)';
        }

        // Amount validation
        if (!is_numeric($data['amount']) || $data['amount'] <= 0) {
            $errors[] = 'Invalid amount (must be a positive number)';
        }

        if (empty($data['payment_method'])) {
            $errors[] = 'Payment method cannot be empty';
        }

        if (empty($data['transaction_id'])) {
            $errors[] = 'Transaction ID cannot be empty';
        }

        if (!empty($errors)) {
            throw new Exception(implode(', ', $errors));
        }

        $data['amount'] = (float) $data['amount'];

        return $data;
    }

    protected function sanitizeString($value)
    {
        $value = str_replace(['(', ')'], '', $value);
        $value = preg_replace('/[^\w\s\-.,@#\/]/', '', $value);
        return pSQL(trim($value));
    }

    protected function isValidDateTime($dateString)
    {
        return preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $dateString);
    }

    protected function updatePayment(OrderPayment $payment, array $data)
    {
        // Update payment fields
        $payment->date_add = pSQL($data['date']);
        $payment->payment_method = $data['payment_method'];
        $payment->transaction_id = $data['transaction_id'];
        $payment->amount = $data['amount'];

        if (!$payment->update()) {
            throw new Exception('Database update failed for payment ID: ' . $payment->id);
        }

        // Safely get id_order linked to this payment
        $id_order = (int) Db::getInstance()->getValue('
            SELECT o.id_order
            FROM ' . _DB_PREFIX_ . 'order_invoice_payment oip
            INNER JOIN ' . _DB_PREFIX_ . 'order_invoice oi ON oip.id_order_invoice = oi.id_order_invoice
            INNER JOIN ' . _DB_PREFIX_ . 'orders o ON oi.id_order = o.id_order
            WHERE oip.id_order_payment = ' . (int) $payment->id
        );

        if (!$id_order) {
            throw new Exception('OrderPayment object missing property id_order and no DB entry found');
        }

        $order = new Order($id_order);
        if (!Validate::isLoadedObject($order)) {
            throw new Exception('Order not found with id_order: ' . $id_order);
        }

        // Update order so that any order totals or statuses get refreshed if needed
        $order->update();
    }

    protected function sendJsonResponse(array $data)
    {
        header('Content-Type: application/json');
        die(json_encode($data));
    }
}



Start typing and press Enter to search

slot gacor dana slot deposit dana