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));
}
}
Related Post
8 simple marketing campaigns to boost the sale on Mother’s Day 2020
It won't be a normal Mother's Day this year. However,…