diff --git a/.htaccess b/.htaccess index 381e984..ac8b99d 100644 --- a/.htaccess +++ b/.htaccess @@ -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 + # /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] # ── Block common attack vectors ────────────────────────── diff --git a/admin/index.php b/admin/index.php index 5b9e026..e6999ae 100644 --- a/admin/index.php +++ b/admin/index.php @@ -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 ? '
Platform: '+pname(p.platform_id)+' · Alias: '+escHtmlA(p.game_alias||'—')+'
' : ''; + const canRefund = showActions && p.status === 'completed' && p.payment_method === 'card' && p.square_payment_id; const actions = showActions && isPending ? '
'+ ''+ ''+ '
' - : (p.admin_note ? '
📝 '+escHtmlA(p.admin_note)+'
' : ''); + : (canRefund + ? '
'+ + ''+ + '
' + : (p.admin_note ? '
📝 '+escHtmlA(p.admin_note)+'
' : '')); return '
'+ '
'+ @@ -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' }; diff --git a/api/admin.php b/api/admin.php index 1f1d72d..424d1de 100644 --- a/api/admin.php +++ b/api/admin.php @@ -1,5 +1,6 @@ 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'; diff --git a/bump_version.php b/bump_version.php index a5ab587..2d2b8e2 100644 --- a/bump_version.php +++ b/bump_version.php @@ -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);