diff --git a/admin/index.php b/admin/index.php index 2208c1d..9d59218 100644 --- a/admin/index.php +++ b/admin/index.php @@ -174,6 +174,65 @@ if ($isAjax) { exit; } + if ($action === 'square_capture') { + $id = (int)($_POST['id'] ?? 0); + $stmt = db()->prepare("SELECT square_payment_id FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + $pid = $b['square_payment_id'] ?? ''; + if (!$pid) { echo json_encode(['error'=>'No payment on file']); exit; } + $resp = squareApi('POST', "/payments/{$pid}/complete"); + if (($resp['payment']['status'] ?? '') === 'COMPLETED') { + db()->prepare("UPDATE bookings SET square_payment_status='COMPLETED', deposit_paid=?, deposit_received=1 WHERE id=?") + ->execute([DEPOSIT_AMOUNT, $id]); + echo json_encode(['ok'=>true,'status'=>'COMPLETED']); + } else { + echo json_encode(['error' => $resp['errors'][0]['detail'] ?? 'Capture failed']); + } + exit; + } + + if ($action === 'square_void') { + $id = (int)($_POST['id'] ?? 0); + $stmt = db()->prepare("SELECT square_payment_id FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + $pid = $b['square_payment_id'] ?? ''; + if (!$pid) { echo json_encode(['error'=>'No payment on file']); exit; } + $resp = squareApi('POST', "/payments/{$pid}/cancel"); + if (($resp['payment']['status'] ?? '') === 'CANCELED') { + db()->prepare("UPDATE bookings SET square_payment_status='CANCELED' WHERE id=?")->execute([$id]); + echo json_encode(['ok'=>true,'status'=>'CANCELED']); + } else { + echo json_encode(['error' => $resp['errors'][0]['detail'] ?? 'Void failed']); + } + exit; + } + + if ($action === 'square_refund') { + $id = (int)($_POST['id'] ?? 0); + $stmt = db()->prepare("SELECT square_payment_id, deposit_paid FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + $pid = $b['square_payment_id'] ?? ''; + if (!$pid) { echo json_encode(['error'=>'No payment on file']); exit; } + $cents = (int)(((float)($b['deposit_paid'] ?: DEPOSIT_AMOUNT)) * 100); + $resp = squareApi('POST', '/refunds', [ + 'idempotency_key' => $pid . '-refund-' . time(), + 'payment_id' => $pid, + 'amount_money' => ['amount' => $cents, 'currency' => 'USD'], + 'reason' => 'Security deposit refund — booking returned in good condition', + ]); + if (!empty($resp['refund']['id'])) { + db()->prepare("UPDATE bookings SET square_payment_status='REFUNDED', square_refund_id=?, deposit_paid=0 WHERE id=?") + ->execute([$resp['refund']['id'], $id]); + echo json_encode(['ok'=>true,'status'=>'REFUNDED']); + } else { + echo json_encode(['error' => $resp['errors'][0]['detail'] ?? 'Refund failed']); + } + exit; + } + if ($action === 'block_date') { $date = $_POST['date'] ?? ''; $reason = substr($_POST['reason'] ?? '', 0, 200); @@ -581,24 +640,47 @@ textarea.notes-ta:focus{border-color:#f97316} - + +
-
- +
+
- Security Deposit Received + Security Deposit — $ - + N/A + Captured — $ charged + Refunded — $ + Hold voided — no charge + Hold active — card authorized, not yet charged + Deposit marked received (manual) + Pending — no card on file yet + -
+
+ + + + + + -
+ +
@@ -831,6 +913,68 @@ function sendReminder(id) { }); } +// ── Square payment actions (capture / void / refund) ───────────────────────── +function squareAction(id, action, btn) { + const labels = { + square_capture: ['Capture', 'Capturing…', 'Captured ✓'], + square_void: ['Void Hold', 'Voiding…', 'Voided ✓'], + square_refund: ['Refund', 'Refunding…', 'Refunded ✓'], + }; + const [orig, working, done] = labels[action]; + const confirmMsg = { + square_capture: 'Charge the deposit hold to this card?', + square_void: 'Void the deposit hold? The customer will NOT be charged.', + square_refund: 'Refund the full deposit to this card?', + }[action]; + if (!confirm(confirmMsg)) return; + btn.disabled = true; + btn.textContent = working; + fetch('/admin/', { + method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'}, + body:'action='+action+'&id='+id + }).then(r=>r.json()).then(d=>{ + if (d.ok) { + btn.textContent = done; + btn.style.background = '#16a34a'; + btn.style.color = '#fff'; + btn.style.border = 'none'; + // Update meta text and dot + const meta = document.getElementById('meta-'+id+'-deposit_received'); + const icon = document.getElementById('icon-'+id+'-deposit_received'); + const dot = document.getElementById('dot-'+id+'-deposit_received'); + if (d.status === 'COMPLETED') { + if (meta) meta.textContent = 'Captured — deposit charged'; + if (icon) { icon.className='flow-icon done'; icon.textContent='✓'; } + if (dot) dot.className='dot dot-done'; + // Replace action area with refund button + const area = document.getElementById('deposit-actions-'+id); + if (area) area.innerHTML = ''; + } else if (d.status === 'CANCELED') { + if (meta) meta.textContent = 'Hold voided — no charge'; + if (icon) { icon.className='flow-icon skip'; icon.textContent='—'; } + if (dot) dot.className='dot dot-skip'; + const area = document.getElementById('deposit-actions-'+id); + if (area) area.innerHTML = ''; + } else if (d.status === 'REFUNDED') { + if (meta) meta.textContent = 'Refunded — deposit returned'; + if (icon) { icon.className='flow-icon pending'; icon.textContent='↩'; } + if (dot) dot.className='dot dot-skip'; + const area = document.getElementById('deposit-actions-'+id); + if (area) area.innerHTML = ''; + } + } else { + btn.textContent = orig; + btn.disabled = false; + alert('Error: ' + (d.error || 'Unknown error')); + } + }).catch(()=>{ + btn.textContent = orig; + btn.disabled = false; + alert('Request failed. Please try again.'); + }); +} + // ── Block / unblock dates ───────────────────────────────────────────────────── function blockDate(e) { e.preventDefault(); diff --git a/contact.php b/contact.php index 64a6b30..9c612de 100644 --- a/contact.php +++ b/contact.php @@ -10,12 +10,13 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_ $input = json_decode(file_get_contents('php://input'), true) ?: $_POST; -$name = trim(strip_tags($input['name'] ?? '')); -$email = trim(strip_tags($input['email'] ?? '')); -$phone = trim(strip_tags($input['phone'] ?? '')); -$package = trim(strip_tags($input['package'] ?? '')); -$date = trim(strip_tags($input['date'] ?? '')); -$message = trim(strip_tags($input['message'] ?? '')); +$name = trim(strip_tags($input['name'] ?? '')); +$email = trim(strip_tags($input['email'] ?? '')); +$phone = trim(strip_tags($input['phone'] ?? '')); +$package = trim(strip_tags($input['package'] ?? '')); +$date = trim(strip_tags($input['date'] ?? '')); +$message = trim(strip_tags($input['message'] ?? '')); +$squareToken = trim($input['square_token'] ?? ''); if (!$name || !$email || !$package || !$date) { http_response_code(400); @@ -113,7 +114,51 @@ $confirmHtml = "
$squareToken, + 'idempotency_key' => $ref . '-dep-' . time(), + 'amount_money' => ['amount' => (int)(DEPOSIT_AMOUNT * 100), 'currency' => 'USD'], + 'autocomplete' => false, // hold only — capture when confirmed + 'location_id' => SQUARE_LOCATION_ID, + 'note' => "Deposit hold — booking {$ref}", + 'reference_id' => $ref, + 'buyer_email_address' => $email, + ]); + if (!empty($sqResp['payment']['id'])) { + $sqId = $sqResp['payment']['id']; + $sqSts = $sqResp['payment']['status']; // APPROVED + db()->prepare("UPDATE bookings SET square_payment_id=?, square_payment_status=? WHERE booking_ref=?") + ->execute([$sqId, $sqSts, $ref]); + $depositStatus = $sqSts; + } +} + +$depositLine = $depositStatus + ? "

Deposit Hold: \$" . number_format(DEPOSIT_AMOUNT, 2) . " authorized (not charged — released if booking is declined)

" + : ''; + +// Inject deposit line into confirmation email +$confirmHtml = str_replace( + "

Total: {$amountLabel}

", + "

Total: {$amountLabel}

{$depositLine}", + $confirmHtml +); + +// Add deposit note to admin email if applicable +if ($depositStatus) { + $adminHtml = str_replace( + "

", + "

✓ \$" . number_format(DEPOSIT_AMOUNT, 2) . " deposit hold authorized (Square — not yet captured)

", + $adminHtml + ); +} + sendEmail(ADMIN_EMAIL, 'Parker Slingshot Admin', "New Booking {$ref}: {$name} — {$pkgLabel} on {$dateLabel}", $adminHtml); sendEmail($email, $name, "Booking Request {$ref} — Parker County Slingshot Rentals", $confirmHtml); -echo json_encode(['success'=>true,'ref'=>$ref,'message'=>"Booking request received! Your reference is {$ref}. We'll be in touch shortly."]); +$msg = "Booking request received! Your reference is {$ref}. We'll be in touch shortly."; +if ($depositStatus) $msg .= " A \$" . number_format(DEPOSIT_AMOUNT, 2) . " refundable deposit hold has been placed on your card."; +echo json_encode(['success'=>true,'ref'=>$ref,'deposit_held'=>(bool)$depositStatus,'square_payment_id'=>$sqId??null,'message'=>$msg]); diff --git a/db.php b/db.php index 9579314..0bb8f32 100644 --- a/db.php +++ b/db.php @@ -13,6 +13,12 @@ define('MAIL_FROM', 'noreply@parkerslingshotrentals.com'); define('MAIL_FROM_NAME', 'Parker County Slingshot Rentals'); define('ADMIN_EMAIL', 'info@parkerslingshotrentals.com'); +define('SQUARE_ACCESS_TOKEN', 'EAAAl3FsAu_2ri8kZE_ENEyi2T_C8HXXm5XQFY6Lbnd8SX6FqYp8J_upUeXNYh7v'); +define('SQUARE_APP_ID', 'sq0idp-YSM7BU9IVyOWSzpeP-0nzQ'); +define('SQUARE_LOCATION_ID', 'L8GZYHYKE95CE'); +define('SQUARE_VERSION', '2024-01-18'); +define('DEPOSIT_AMOUNT', 100.00); // $100 refundable security deposit hold + define('PACKAGES', [ 'half-day' => ['label' => 'Half Day (4 hrs)', 'amount' => 99.00, 'days' => 0], 'full-day' => ['label' => 'Full Day (8 hrs)', 'amount' => 169.00, 'days' => 0], @@ -31,6 +37,24 @@ function db(): PDO { return $pdo; } +function squareApi(string $method, string $path, array $body = []): array { + $ch = curl_init('https://connect.squareup.com/v2' . $path); + $headers = [ + 'Authorization: Bearer ' . SQUARE_ACCESS_TOKEN, + 'Content-Type: application/json', + 'Square-Version: ' . SQUARE_VERSION, + ]; + $opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => false]; + if ($method === 'POST') { + $opts[CURLOPT_POST] = true; + $opts[CURLOPT_POSTFIELDS] = $body ? json_encode($body) : '{}'; + } + curl_setopt_array($ch, $opts); + $resp = curl_exec($ch); + curl_close($ch); + return json_decode($resp ?: '{}', true); +} + function generateRef(): string { return 'PSR-' . strtoupper(substr(uniqid(), -6)); } diff --git a/index.html b/index.html index 2681644..d4af8bf 100644 --- a/index.html +++ b/index.html @@ -811,7 +811,17 @@ - + + +

+

Refundable Deposit — $100

+

A $100 hold will be placed on your card — not charged until your booking is confirmed. Released in full if declined or at return.

+
+ + +
+ +
@@ -834,6 +844,7 @@

Polaris Slingshot® is a registered trademark of Polaris Inc. We are an independent rental operator.

+