Add Square deposit payment integration

- Square Web Payments SDK card element in booking form
- Delayed-capture hold ($100) on booking submit — not charged until confirmed
- Live payment status field: Verifying card → Authorizing → Confirmed w/ hold ID
- Admin: Capture / Void / Refund actions for each booking
- square_payment_id returned in API response for frontend confirmation display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 18:33:16 +00:00
parent 8f5362aa95
commit cca3129f6e
4 changed files with 324 additions and 35 deletions
+151 -7
View File
@@ -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}
</div>
</div>
<!-- Step 5: Deposit -->
<!-- Step 5: Deposit (Square-aware) -->
<?php
$sqStatus = $b['square_payment_status'] ?? '';
$sqId = $b['square_payment_id'] ?? '';
$depositIcon = 'pending';
$depositLabel = '5';
if ($cancelled) { $depositIcon='skip'; $depositLabel='—'; }
elseif ($stepDeposit ||
$sqStatus==='COMPLETED') { $depositIcon='done'; $depositLabel='✓'; }
?>
<div class="flow-step">
<div class="flow-icon <?= $stepDeposit?'done':($cancelled?'skip':'pending') ?>" id="icon-<?= $bid ?>-deposit_received">
<?= $stepDeposit?'✓':($cancelled?'—':'5') ?>
<div class="flow-icon <?= $depositIcon ?>" id="icon-<?= $bid ?>-deposit_received">
<?= $depositLabel ?>
</div>
<div class="flow-body">
<span class="flow-label">Security Deposit Received</span>
<span class="flow-label">Security Deposit — $<?= number_format(DEPOSIT_AMOUNT,0) ?></span>
<span class="flow-meta" id="meta-<?= $bid ?>-deposit_received">
<?= $stepDeposit?'Deposit received':($cancelled?'N/A':'Pending — collect at pickup') ?>
<?php if ($cancelled): ?>N/A
<?php elseif ($sqStatus === 'COMPLETED'): ?>Captured — $<?= number_format((float)($b['deposit_paid']??DEPOSIT_AMOUNT),2) ?> charged
<?php elseif ($sqStatus === 'REFUNDED'): ?>Refunded — $<?= number_format((float)($b['deposit_paid']??DEPOSIT_AMOUNT),2) ?>
<?php elseif ($sqStatus === 'CANCELED'): ?>Hold voided — no charge
<?php elseif ($sqStatus === 'APPROVED' || $sqStatus === 'PENDING'): ?>Hold active — card authorized, not yet charged
<?php elseif ($stepDeposit): ?>Deposit marked received (manual)
<?php else: ?>Pending — no card on file yet
<?php endif; ?>
</span>
<?php if (!$cancelled): ?>
<div class="flow-action">
<div class="flow-action" id="deposit-actions-<?= $bid ?>">
<?php if ($sqStatus === 'APPROVED' || $sqStatus === 'PENDING'): ?>
<button class="flow-toggle" onclick="squareAction(<?= $bid ?>,'square_capture',this)" style="margin-right:4px">Capture $<?= number_format(DEPOSIT_AMOUNT,0) ?></button>
<button class="flow-toggle" onclick="squareAction(<?= $bid ?>,'square_void',this)" style="border-color:#dc2626;color:#dc2626">Void Hold</button>
<?php elseif ($sqStatus === 'COMPLETED'): ?>
<button class="flow-toggle" onclick="squareAction(<?= $bid ?>,'square_refund',this)" style="border-color:#2563eb;color:#2563eb">Refund $<?= number_format((float)($b['deposit_paid']??DEPOSIT_AMOUNT),2) ?></button>
<?php elseif (!$sqId): ?>
<button class="flow-toggle <?= $stepDeposit?'active':'' ?>"
id="btn-<?= $bid ?>-deposit_received"
onclick="toggleReq(<?= $bid ?>,'deposit_received',this)">
<?= $stepDeposit?'✓ Deposit Received':'Mark Deposit Received' ?>
</button>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
@@ -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 = '<button class="flow-toggle" onclick="squareAction('+id+',\'square_refund\',this)" style="border-color:#2563eb;color:#2563eb">Refund Deposit</button>';
} 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();