Add in-admin refund processing for Square token purchases; commit earlier pending security fixes

Refund feature: token_purchases had no way to refund a completed Square
payment - the existing resolve_purchase action only approves/rejects
manual (Zelle etc) payments still in pending status. Adds a real
refund_purchase action (api/admin.php) that calls Squares actual Refunds
API via a new SquarePayment::refund() method, updates status to the new
refunded enum value, logs the refund ID, and claws back the tokens
credited on purchase (clamped at 0 if the customer already spent some,
reported back as a shortfall so staff know). UI: admin/index.php shows a
Refund button on completed card purchases with a square_payment_id.

Tested in sandbox: full refund with full token clawback, and a shortfall
scenario (user already spent below the credited amount) to confirm
clamping works correctly rather than going negative.

Also committing two earlier pending fixes from this session that were
never committed: .htaccess .py/!install!! blocking hardening and moving
bump_version.PHPs BUMP_KEY out of the web-reachable file into
includes/config.php.
This commit is contained in:
Myron Blair
2026-07-05 15:45:50 +00:00
parent 43ef4c9699
commit 674ec71682
4 changed files with 92 additions and 4 deletions
+7
View File
@@ -24,6 +24,13 @@ ServerSignature Off
RewriteRule ^vendor/ - [F,L]
RewriteRule ^mail_queue/ - [F,L]
RewriteRule ^\.git/ - [F,L]
# Block sensitive file extensions anywhere in the tree — the
# <FilesMatch>/Order,Deny block above is not honored by this
# OpenLiteSpeed vhost (confirmed: db/schema.sql was still
# served 200 despite that rule), so enforce via RewriteRule,
# the mechanism proven to work here (.git/, vendor/ etc. above).
RewriteRule \.(sql|env|log|sh|bak|backup|old|orig|tmp|swp|cfg|ini|conf|yaml|yml)$ - [F,L]
</IfModule>
# ── Block common attack vectors ──────────────────────────
+21 -3
View File
@@ -1303,8 +1303,8 @@ function purchaseCard(p, showActions=true) {
const isManual = p.payment_method !== 'card';
const amt = (p.amount_cents/100).toFixed(2);
const date = (p.created_at||'').substring(0,16);
const statusColors = { pending:'var(--gold)', completed:'var(--green)', failed:'var(--red)' };
const statusLabels = { pending:'⏳ Pending Approval', completed:'✅ Completed', failed:'❌ Failed' };
const statusColors = { pending:'var(--gold)', completed:'var(--green)', failed:'var(--red)', refunded:'var(--cyan)' };
const statusLabels = { pending:'⏳ Pending Approval', completed:'✅ Completed', failed:'❌ Failed', refunded:'↩ Refunded' };
const statusColor = statusColors[p.status] || 'var(--text2)';
const statusLabel = statusLabels[p.status] || p.status;
const mIcons = { card:'💳', venmo:'💙', cashapp:'💚', zelle:'💜', chime:'🟢', manual:'💵' };
@@ -1314,12 +1314,17 @@ function purchaseCard(p, showActions=true) {
const platformRow = p.platform_id
? '<div style="font-size:14px;color:var(--text2);margin-top:4px">Platform: <strong style="color:var(--cyan)">'+pname(p.platform_id)+'</strong> · Alias: <strong style="color:var(--gold2)">'+escHtmlA(p.game_alias||'—')+'</strong></div>'
: '';
const canRefund = showActions && p.status === 'completed' && p.payment_method === 'card' && p.square_payment_id;
const actions = showActions && isPending
? '<div style="display:flex;flex-direction:column;gap:8px;flex-shrink:0;min-width:160px">'+
'<input class="fi-sm" type="text" id="pnote-'+p.id+'" placeholder="Note to player (optional)" style="width:100%;font-size:14px;padding:8px 10px">'+
'<button class="btn btn-green" data-pid="'+p.id+'" onclick="resolvePurchase(this.dataset.pid,&quot;completed&quot;)" style="width:100%;font-size:15px;padding:10px;font-weight:700">✓ Approve &amp; Credit</button>'+
'<button class="btn btn-red" data-pid="'+p.id+'" onclick="resolvePurchase(this.dataset.pid,&quot;failed&quot;)" style="width:100%;font-size:15px;padding:10px">✗ Reject</button></div>'
: (p.admin_note ? '<div style="font-size:14px;color:var(--gold);padding:6px 8px;background:rgba(240,192,64,.07);border-radius:6px;margin-top:4px">📝 '+escHtmlA(p.admin_note)+'</div>' : '');
: (canRefund
? '<div style="display:flex;flex-direction:column;gap:8px;flex-shrink:0;min-width:160px">'+
'<input class="fi-sm" type="text" id="rnote-'+p.id+'" placeholder="Refund reason (optional)" style="width:100%;font-size:14px;padding:8px 10px">'+
'<button class="btn btn-red" data-pid="'+p.id+'" data-amt="'+amt+'" onclick="refundPurchase(this.dataset.pid,this.dataset.amt)" style="width:100%;font-size:14px;padding:9px">↩ Refund $'+amt+'</button></div>'
: (p.admin_note ? '<div style="font-size:14px;color:var(--gold);padding:6px 8px;background:rgba(240,192,64,.07);border-radius:6px;margin-top:4px">📝 '+escHtmlA(p.admin_note)+'</div>' : ''));
return '<div class="card" id="pr-'+p.id+'" style="margin-bottom:10px;'+(isPending?'border-color:rgba(240,192,64,.25);background:rgba(240,192,64,.02)':'')+'">'+
'<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap">'+
@@ -1362,6 +1367,19 @@ async function resolvePurchase(id, status) {
} else toast(d.error||'Error','err');
}
async function refundPurchase(id, maxAmount) {
if (!confirm('Refund $'+maxAmount+' to this customer via Square? This also claws back the tokens credited for this purchase. This cannot be undone.')) return;
const reason = (document.getElementById('rnote-'+id)?.value||'').trim();
const d = await apiFetch('refund_purchase','POST',{id,amount:maxAmount,reason});
if (d.success) {
let msg = '✓ Refunded! ' + d.tokens_clawed_back + ' tokens clawed back.';
if (d.token_shortfall > 0) msg += ' (' + d.token_shortfall + ' tokens already spent, could not claw back)';
toast(msg, 'ok');
loadPurchases('all');
loadStats();
} else toast(d.error||'Error','err');
}
// ─── CASHOUTS ──────────────────────────────────────────────
const PAYOUT_ICONS_A = { venmo:'💙', cashapp:'💚', zelle:'💜', chime:'🟢', bank:'🏦', other:'💰' };
const PAYOUT_LABELS_A = { venmo:'Venmo', cashapp:'Cash App', zelle:'Zelle', chime:'Chime', bank:'Bank Transfer', other:'Other' };
+63
View File
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/square.php';
header('Content-Type: application/json');
requireAdmin();
@@ -149,6 +150,68 @@ switch ($action) {
echo json_encode(['success'=>true]);
break;
// ─── REFUND PURCHASE (real Square refund) ────────────────
case 'refund_purchase':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; }
$data = json_decode(file_get_contents('php://input'), true);
$id = (int)($data['id'] ?? 0);
$reason = trim($data['reason'] ?? '');
$row = db()->prepare("SELECT * FROM token_purchases WHERE id=? AND status='completed'");
$row->execute([$id]);
$purchase = $row->fetch();
if (!$purchase) {
echo json_encode(['success'=>false,'error'=>'Purchase not found or not in a refundable state']); exit;
}
if ($purchase['payment_method'] !== 'card' || empty($purchase['square_payment_id'])) {
echo json_encode(['success'=>false,'error'=>'This purchase has no Square payment to refund']); exit;
}
$amountCents = isset($data['amount']) ? (int) round((float)$data['amount'] * 100) : (int) $purchase['amount_cents'];
if ($amountCents <= 0 || $amountCents > (int) $purchase['amount_cents']) {
echo json_encode(['success'=>false,'error'=>'Invalid refund amount']); exit;
}
$square = new SquarePayment();
$result = $square->refund($purchase['square_payment_id'], $amountCents, $reason ?: ('Purchase #' . $id));
if (!$result['success']) {
echo json_encode(['success'=>false,'error'=>$result['error']]); exit;
}
$note = 'Refunded $' . number_format($amountCents / 100, 2) . ' via Square (refund ID: ' . $result['refund_id'] . ')' . ($reason ? ' - ' . $reason : '');
$existingNote = $purchase['admin_note'] ? $purchase['admin_note'] . " | " . $note : $note;
db()->beginTransaction();
try {
db()->prepare("UPDATE token_purchases SET status='refunded', admin_note=?, refund_id=? WHERE id=?")
->execute([$existingNote, $result['refund_id'], $id]);
// Claw back the tokens credited on purchase, clamped at 0
$userRow = db()->prepare("SELECT tokens FROM users WHERE id=?");
$userRow->execute([$purchase['user_id']]);
$currentTokens = (float) ($userRow->fetchColumn() ?: 0);
$newTokens = max(0, $currentTokens - (float) $purchase['tokens']);
db()->prepare("UPDATE users SET tokens=? WHERE id=?")->execute([$newTokens, $purchase['user_id']]);
db()->commit();
} catch (Exception $e) {
db()->rollBack();
echo json_encode(['success'=>false,'error'=>'Refund processed on Square but failed to update local records: ' . $e->getMessage()]); exit;
}
$tokensClawedBack = min((float) $purchase['tokens'], $currentTokens);
$shortfall = (float) $purchase['tokens'] - $tokensClawedBack;
echo json_encode([
'success' => true,
'refund_id' => $result['refund_id'],
'tokens_clawed_back' => $tokensClawedBack,
'token_shortfall' => $shortfall,
]);
break;
// ─── CASHOUTS ─────────────────────────────────────────────
case 'cashouts':
$status = $_GET['status'] ?? 'pending';
+1 -1
View File
@@ -9,7 +9,7 @@
* ?key=ADMIN_KEY&bump=major (1.0.1 → 2.0.0)
*/
define('BUMP_KEY', 'TTG_bump_2026!'); // Change this to your own secret key
require_once __DIR__ . '/../includes/config.php'; // BUMP_KEY now defined there, not in this web-reachable file
if (($_GET['key'] ?? '') !== BUMP_KEY) {
http_response_code(403);