Files
Myron Blair 3c8e4d1dbc Add in-admin refund processing via Square RefundPayment API
admin/order.php previously had no real refund action - the "refunded"
status option was purely cosmetic, only setting order_status without
touching payment_status or calling any payment API. Adds a Refund card
(full or partial amount, optional reason) that calls the real Square
Refunds API for orders paid via Square, updates payment_status
(refunded/partially_refunded) and order_status, logs the refund ID as an
order note, and restores any wallet_amount_used back to the customer
wallet on a full refund (mirroring how it was deducted on payment
success in markSquarePaymentResult). Also fixed the Payment sidebar card
to display square_payment_id (it only ever showed the old stripe_payment_intent
field, even for Square orders).

Tested in sandbox: full refund (wallet restore verified), and confirmed
Squares own over-refund rejection surfaces cleanly as a flash error
rather than a crash.
2026-07-05 15:30:15 +00:00

416 lines
19 KiB
PHP

<?php
/**
* Tom's Java Jive - Admin Order Detail
*/
ob_start();
$pageTitle = 'Order Details';
require_once __DIR__ . '/includes/header.php';
require_once __DIR__ . '/../includes/square.php';
$orderId = $_GET['id'] ?? '';
if (empty($orderId)) {
header('Location: /admin/orders.php');
exit;
}
$order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]);
if (!$order) {
setFlash('error', 'Order not found');
header('Location: /admin/orders.php');
exit;
}
// Handle status update
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'update_status') {
$status = $_POST['status'] ?? '';
$trackingNumber = $_POST['tracking_number'] ?? '';
$updateData = ['order_status' => $status];
if ($trackingNumber) {
$updateData['tracking_number'] = $trackingNumber;
}
db()->update('orders', $updateData, 'order_id = :id', ['id' => $orderId]);
setFlash('success', 'Order status updated');
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
if ($action === 'add_note') {
$note = trim($_POST['note'] ?? '');
if ($note) {
$existingNotes = $order['notes'] ?? '';
$newNote = '[' . date('M j, Y g:i A') . '] ' . $note;
$allNotes = $existingNotes ? $existingNotes . "\n" . $newNote : $newNote;
db()->update('orders', ['notes' => $allNotes], 'order_id = :id', ['id' => $orderId]);
setFlash('success', 'Note added');
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
}
if ($action === 'process_refund') {
$refundAmount = (float) ($_POST['refund_amount'] ?? 0);
$reason = trim($_POST['refund_reason'] ?? '');
if ($order['payment_method'] !== 'square' || empty($order['square_payment_id'])) {
setFlash('error', 'This order has no Square payment to refund.');
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
if ($order['payment_status'] !== 'paid' && $order['payment_status'] !== 'partially_refunded') {
setFlash('error', 'Only paid orders can be refunded.');
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
if ($refundAmount <= 0 || $refundAmount > (float) $order['total']) {
setFlash('error', 'Invalid refund amount.');
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
try {
$result = squareRefundPayment($order['square_payment_id'], $refundAmount, $reason ?: ('Order #' . $order['order_number']));
$refund = $result['refund'] ?? [];
$refundStatus = $refund['status'] ?? '';
if (in_array($refundStatus, ['PENDING', 'COMPLETED'], true)) {
$isFullRefund = $refundAmount >= (float) $order['total'];
$newPaymentStatus = $isFullRefund ? 'refunded' : 'partially_refunded';
db()->update('orders',
['payment_status' => $newPaymentStatus, 'order_status' => $isFullRefund ? 'refunded' : $order['order_status']],
'order_id = :id',
['id' => $orderId]
);
// Restore any wallet credit that was applied, proportionally on a full refund
if ($isFullRefund && !empty($order['wallet_amount_used']) && (float) $order['wallet_amount_used'] > 0 && !empty($order['customer_id'])) {
$walletAmount = (float) $order['wallet_amount_used'];
$cust = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $order['customer_id']]);
if ($cust) {
$newBalance = (float) $cust['wallet_balance'] + $walletAmount;
db()->update('customers', ['wallet_balance' => $newBalance], 'customer_id = :id', ['id' => $order['customer_id']]);
db()->insert('wallet_transactions', [
'transaction_id' => generateId('wt_'),
'customer_id' => $order['customer_id'],
'amount' => $walletAmount,
'balance_after' => $newBalance,
'type' => 'refund',
'description' => 'Refund for Order #' . $order['order_number'],
]);
}
}
$existingNotes = $order['notes'] ?? '';
$newNote = '[' . date('M j, Y g:i A') . '] Refunded ' . formatCurrency($refundAmount)
. ' via Square (refund ID: ' . ($refund['id'] ?? 'unknown') . ')'
. ($reason ? ' — ' . $reason : '');
$allNotes = $existingNotes ? $existingNotes . "\n" . $newNote : $newNote;
db()->update('orders', ['notes' => $allNotes], 'order_id = :id', ['id' => $orderId]);
setFlash('success', formatCurrency($refundAmount) . ' refunded successfully.');
} else {
setFlash('error', 'Refund did not complete (status: ' . $refundStatus . ').');
}
} catch (Exception $e) {
setFlash('error', 'Refund failed: ' . $e->getMessage());
}
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
}
$items = json_decode($order['items'], true) ?? [];
$shippingAddress = json_decode($order['shipping_address'], true) ?? [];
$statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
$canRefund = $order['payment_method'] === 'square'
&& !empty($order['square_payment_id'])
&& in_array($order['payment_status'], ['paid', 'partially_refunded'], true);
?>
<div class="page-header">
<div style="display: flex; align-items: center; gap: 1rem;">
<a href="/admin/orders.php" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
</a>
<h1 class="page-title">Order #<?= htmlspecialchars($order['order_number']) ?></h1>
<?php if ($order['is_pos_order']): ?>
<span class="badge badge-primary">POS Order</span>
<?php endif; ?>
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" onclick="window.print()">
<i class="fas fa-print"></i> Print
</button>
</div>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
<!-- Main Column -->
<div>
<!-- Order Items -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Order Items</h3>
</div>
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Qty</th>
<th style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<tr>
<td>
<?php if (isset($item['product_id'])): ?>
<a href="/admin/product-edit.php?id=<?= $item['product_id'] ?>">
<?= htmlspecialchars($item['name']) ?>
</a>
<?php else: ?>
<?= htmlspecialchars($item['name']) ?>
<?php endif; ?>
</td>
<td><?= formatCurrency($item['price']) ?></td>
<td><?= $item['quantity'] ?></td>
<td style="text-align: right;"><?= formatCurrency($item['total']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td colspan="3" style="text-align: right;">Subtotal</td>
<td style="text-align: right;"><?= formatCurrency($order['subtotal']) ?></td>
</tr>
<?php if ($order['shipping_cost'] > 0): ?>
<tr>
<td colspan="3" style="text-align: right;">Shipping</td>
<td style="text-align: right;"><?= formatCurrency($order['shipping_cost']) ?></td>
</tr>
<?php endif; ?>
<?php if (($order['tax'] ?? 0) > 0): ?>
<tr>
<td colspan="3" style="text-align: right;">Tax</td>
<td style="text-align: right;"><?= formatCurrency($order['tax']) ?></td>
</tr>
<?php endif; ?>
<?php if (($order['discount'] ?? 0) > 0): ?>
<tr>
<td colspan="3" style="text-align: right;">Discount</td>
<td style="text-align: right; color: var(--admin-success);">-<?= formatCurrency($order['discount']) ?></td>
</tr>
<?php endif; ?>
<?php if (($order['wallet_amount_used'] ?? 0) > 0): ?>
<tr>
<td colspan="3" style="text-align: right;">Wallet / Gift Card</td>
<td style="text-align: right; color: var(--admin-success);">-<?= formatCurrency($order['wallet_amount_used']) ?></td>
</tr>
<?php endif; ?>
<tr style="font-size: 1.125rem; font-weight: 600;">
<td colspan="3" style="text-align: right;">Total</td>
<td style="text-align: right;"><?= formatCurrency($order['total']) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Notes -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Order Notes</h3>
</div>
<div class="admin-card-body">
<?php if ($order['notes']): ?>
<pre style="white-space: pre-wrap; font-family: inherit; margin-bottom: 1rem; padding: 1rem; background: var(--admin-bg); border-radius: var(--admin-radius);"><?= htmlspecialchars($order['notes']) ?></pre>
<?php else: ?>
<p class="text-muted" style="margin-bottom: 1rem;">No notes yet.</p>
<?php endif; ?>
<form method="POST">
<input type="hidden" name="action" value="add_note">
<div class="form-group mb-0">
<div style="display: flex; gap: 0.5rem;">
<input type="text" name="note" class="form-input" placeholder="Add a note...">
<button type="submit" class="btn btn-secondary">Add Note</button>
</div>
</div>
</form>
</div>
</div>
<?php if ($canRefund): ?>
<!-- Refund -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Process Refund</h3>
</div>
<div class="admin-card-body">
<form method="POST" onsubmit="return confirm('Refund ' + document.getElementById('refund_amount').value + ' via Square? This cannot be undone.');">
<input type="hidden" name="action" value="process_refund">
<div class="form-group">
<label class="form-label">Amount</label>
<input type="number" step="0.01" min="0.01" max="<?= $order['total'] ?>" name="refund_amount" id="refund_amount" class="form-input" value="<?= $order['total'] ?>" required>
<small class="text-muted">Up to <?= formatCurrency($order['total']) ?> (full order total). Enter a lower amount for a partial refund.</small>
</div>
<div class="form-group">
<label class="form-label">Reason (optional)</label>
<input type="text" name="refund_reason" class="form-input" placeholder="e.g. damaged item, customer request">
</div>
<button type="submit" class="btn btn-secondary btn-block">
<i class="fas fa-undo"></i> Refund via Square
</button>
</form>
</div>
</div>
<?php endif; ?>
</div>
<!-- Sidebar -->
<div>
<!-- Status -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Order Status</h3>
</div>
<div class="admin-card-body">
<form method="POST">
<input type="hidden" name="action" value="update_status">
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<?php foreach ($statuses as $s): ?>
<option value="<?= $s ?>" <?= $order['order_status'] === $s ? 'selected' : '' ?>>
<?= ucfirst($s) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Tracking Number</label>
<input type="text" name="tracking_number" class="form-input" value="<?= htmlspecialchars($order['tracking_number'] ?? '') ?>">
</div>
<button type="submit" class="btn btn-primary btn-block">Update Status</button>
</form>
</div>
</div>
<!-- Customer -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Customer</h3>
</div>
<div class="admin-card-body">
<p><strong><?= htmlspecialchars($order['customer_name']) ?></strong></p>
<p><?= htmlspecialchars($order['customer_email']) ?></p>
<?php if ($order['customer_phone']): ?>
<p><?= htmlspecialchars($order['customer_phone']) ?></p>
<?php endif; ?>
<?php if ($order['customer_id']): ?>
<a href="/admin/customers.php?id=<?= $order['customer_id'] ?>" class="btn btn-sm btn-secondary mt-1">
View Customer
</a>
<?php endif; ?>
</div>
</div>
<!-- Shipping Address -->
<?php if (!empty($shippingAddress) && ($shippingAddress['type'] ?? '') !== 'pickup'): ?>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Shipping Address</h3>
</div>
<div class="admin-card-body">
<p>
<?= htmlspecialchars($shippingAddress['address'] ?? '') ?><br>
<?= htmlspecialchars($shippingAddress['city'] ?? '') ?>,
<?= htmlspecialchars($shippingAddress['state'] ?? '') ?>
<?= htmlspecialchars($shippingAddress['zip'] ?? '') ?>
</p>
</div>
</div>
<?php endif; ?>
<!-- Payment -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Payment</h3>
</div>
<div class="admin-card-body">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Method</span>
<span><?= ucfirst($order['payment_method'] ?? 'N/A') ?></span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Status</span>
<?php
$paymentClass = match($order['payment_status']) {
'paid' => 'success',
'failed' => 'error',
'refunded', 'partially_refunded' => 'warning',
default => 'primary'
};
?>
<span class="badge badge-<?= $paymentClass ?>"><?= ucfirst(str_replace('_', ' ', $order['payment_status'])) ?></span>
</div>
<?php if (!empty($order['square_payment_id'])): ?>
<div style="display: flex; justify-content: space-between;">
<span>Square Payment ID</span>
<span class="text-muted" style="font-size: 0.8rem;"><?= htmlspecialchars(substr($order['square_payment_id'], 0, 20)) ?></span>
</div>
<?php elseif ($order['stripe_payment_intent']): ?>
<div style="display: flex; justify-content: space-between;">
<span>Stripe ID</span>
<span class="text-muted" style="font-size: 0.8rem;"><?= htmlspecialchars(substr($order['stripe_payment_intent'], 0, 20)) ?>...</span>
</div>
<?php endif; ?>
</div>
</div>
<!-- Timeline -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Timeline</h3>
</div>
<div class="admin-card-body">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span class="text-muted">Created</span>
<span><?= formatDateTime($order['created_at']) ?></span>
</div>
<?php if ($order['updated_at'] && $order['updated_at'] !== $order['created_at']): ?>
<div style="display: flex; justify-content: space-between;">
<span class="text-muted">Updated</span>
<span><?= formatDateTime($order['updated_at']) ?></span>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>