diff --git a/admin/payments.php b/admin/payments.php
index 2c96e61..81d77e1 100644
--- a/admin/payments.php
+++ b/admin/payments.php
@@ -9,7 +9,7 @@ require_once __DIR__ . '/includes/header.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$section = $_POST['section'] ?? '';
-
+
if ($section === 'stripe') {
setSetting('payment_stripe', [
'enabled' => isset($_POST['stripe_enabled']),
@@ -20,7 +20,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]);
setFlash('success', 'Stripe settings updated');
}
-
+
+ if ($section === 'square') {
+ setSetting('payment_square', [
+ 'enabled' => isset($_POST['square_enabled']),
+ 'sandbox' => isset($_POST['square_sandbox']),
+ 'app_id' => trim($_POST['square_app_id'] ?? ''),
+ 'location_id' => trim($_POST['square_location_id'] ?? ''),
+ 'access_token' => trim($_POST['square_access_token'] ?? ''),
+ 'webhook_signature_key' => trim($_POST['square_webhook_signature_key'] ?? '')
+ ]);
+ setFlash('success', 'Square settings updated');
+ }
+
if ($section === 'methods') {
setSetting('payment_methods', [
'card' => isset($_POST['method_card']),
@@ -30,7 +42,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]);
setFlash('success', 'Payment methods updated');
}
-
+
header('Location: /admin/payments.php');
exit;
}
@@ -43,6 +55,15 @@ $stripe = getSetting('payment_stripe', [
'webhook_secret' => ''
]);
+$square = getSetting('payment_square', [
+ 'enabled' => defined('PAYMENT_PROCESSOR') && PAYMENT_PROCESSOR === 'square',
+ 'sandbox' => defined('SQUARE_ENV') && SQUARE_ENV === 'sandbox',
+ 'app_id' => defined('SQUARE_APP_ID') ? SQUARE_APP_ID : '',
+ 'location_id' => defined('SQUARE_LOCATION_ID') ? SQUARE_LOCATION_ID : '',
+ 'access_token' => defined('SQUARE_ACCESS_TOKEN') ? SQUARE_ACCESS_TOKEN : '',
+ 'webhook_signature_key' => defined('SQUARE_WEBHOOK_SIGNATURE_KEY') ? SQUARE_WEBHOOK_SIGNATURE_KEY : ''
+]);
+
$methods = getSetting('payment_methods', [
'card' => true,
'cash' => true,
@@ -70,14 +91,70 @@ $methods = getSetting('payment_methods', [
-
+
diff --git a/api/create-checkout-session.php b/api/create-checkout-session.php
deleted file mode 100644
index 61f2117..0000000
--- a/api/create-checkout-session.php
+++ /dev/null
@@ -1,129 +0,0 @@
- 'Method not allowed'], 405);
-}
-
-$input = json_decode(file_get_contents('php://input'), true);
-$orderId = $input['order_id'] ?? '';
-$originUrl = $input['origin_url'] ?? '';
-
-if (empty($orderId)) {
- jsonResponse(['error' => 'Order ID required'], 400);
-}
-
-if (empty($originUrl)) {
- $originUrl = SITE_URL;
-}
-
-// Get order
-$order = db()->fetch(
- "SELECT * FROM orders WHERE order_id = :id",
- ['id' => $orderId]
-);
-
-if (!$order) {
- jsonResponse(['error' => 'Order not found'], 404);
-}
-
-if ($order['payment_status'] === 'paid') {
- jsonResponse(['error' => 'Order already paid'], 400);
-}
-
-// Check if Stripe is configured
-if (!isStripeConfigured()) {
- // Demo mode - simulate successful payment
- db()->update('orders',
- [
- 'payment_status' => 'paid',
- 'order_status' => 'confirmed',
- 'stripe_payment_intent' => 'demo_' . bin2hex(random_bytes(8))
- ],
- 'order_id = :id',
- ['id' => $orderId]
- );
-
- if (!empty($order['customer_id'])) {
- loyalty()->awardPoints(
- $order['customer_id'],
- (float) $order['total'],
- 'Order #' . $order['order_number'],
- $orderId
- );
- }
-
- jsonResponse([
- 'demo_mode' => true,
- 'message' => 'Payment simulated (Stripe not configured)',
- 'redirect' => '/order-confirmation.php?order=' . $orderId
- ]);
-}
-
-// Build line items from order
-$items = json_decode($order['items'], true) ?? [];
-$lineItems = [];
-
-foreach ($items as $item) {
- $lineItems[] = [
- 'name' => $item['name'],
- 'price' => floatval($item['price']),
- 'quantity' => intval($item['quantity']),
- 'currency' => 'usd'
- ];
-}
-
-// Add shipping if applicable
-if ($order['shipping_cost'] > 0) {
- $lineItems[] = [
- 'name' => 'Shipping',
- 'price' => floatval($order['shipping_cost']),
- 'quantity' => 1,
- 'currency' => 'usd'
- ];
-}
-
-// Build success/cancel URLs
-$successUrl = rtrim($originUrl, '/') . '/order-confirmation.php?order=' . $orderId . '&session_id={CHECKOUT_SESSION_ID}';
-$cancelUrl = rtrim($originUrl, '/') . '/payment.php?order=' . $orderId . '&cancelled=1';
-
-try {
- $session = stripe()->createCheckoutSession(
- $lineItems,
- $successUrl,
- $cancelUrl,
- [
- 'customer_email' => $order['customer_email'],
- 'metadata' => [
- 'order_id' => $orderId,
- 'order_number' => $order['order_number']
- ]
- ]
- );
-
- // Store checkout session ID
- db()->update('orders',
- ['stripe_session_id' => $session['id']],
- 'order_id = :id',
- ['id' => $orderId]
- );
-
- jsonResponse([
- 'url' => $session['url'],
- 'session_id' => $session['id']
- ]);
-
-} catch (Exception $e) {
- error_log('Stripe Checkout error: ' . $e->getMessage());
- jsonResponse(['error' => 'Failed to create checkout session: ' . $e->getMessage()], 500);
-}
diff --git a/api/create-payment-intent.php b/api/create-payment-intent.php
deleted file mode 100644
index ae8ca5a..0000000
--- a/api/create-payment-intent.php
+++ /dev/null
@@ -1,87 +0,0 @@
- 'Method not allowed'], 405);
-}
-
-$input = json_decode(file_get_contents('php://input'), true);
-$orderId = $input['order_id'] ?? '';
-
-if (empty($orderId)) {
- jsonResponse(['error' => 'Order ID required'], 400);
-}
-
-// Get order
-$order = db()->fetch(
- "SELECT * FROM orders WHERE order_id = :id",
- ['id' => $orderId]
-);
-
-if (!$order) {
- jsonResponse(['error' => 'Order not found'], 404);
-}
-
-if ($order['payment_status'] === 'paid') {
- jsonResponse(['error' => 'Order already paid'], 400);
-}
-
-// Check if Stripe is configured
-if (!isStripeConfigured()) {
- // Demo mode - simulate successful payment
- db()->update('orders',
- [
- 'payment_status' => 'paid',
- 'order_status' => 'confirmed',
- 'stripe_payment_intent' => 'demo_' . bin2hex(random_bytes(8))
- ],
- 'order_id = :id',
- ['id' => $orderId]
- );
-
- jsonResponse([
- 'demo_mode' => true,
- 'message' => 'Payment simulated (Stripe not configured)',
- 'redirect' => '/order-confirmation.php?order=' . $orderId
- ]);
-}
-
-// Create Stripe Payment Intent using cURL-based API
-try {
- $paymentIntent = stripe()->createPaymentIntent(
- $order['total'],
- 'usd',
- [
- 'metadata' => [
- 'order_id' => $orderId,
- 'order_number' => $order['order_number']
- ],
- 'receipt_email' => $order['customer_email'],
- 'description' => 'Order #' . $order['order_number']
- ]
- );
-
- // Store payment intent ID
- db()->update('orders',
- ['stripe_payment_intent' => $paymentIntent['id']],
- 'order_id = :id',
- ['id' => $orderId]
- );
-
- jsonResponse([
- 'client_secret' => $paymentIntent['client_secret']
- ]);
-
-} catch (Exception $e) {
- error_log('Stripe error: ' . $e->getMessage());
- jsonResponse(['error' => 'Payment initialization failed: ' . $e->getMessage()], 500);
-}
diff --git a/api/create-square-payment.php b/api/create-square-payment.php
new file mode 100644
index 0000000..a32e1dc
--- /dev/null
+++ b/api/create-square-payment.php
@@ -0,0 +1,94 @@
+ 'Method not allowed'], 405);
+}
+
+$input = json_decode(file_get_contents('php://input'), true);
+$orderId = $input['order_id'] ?? '';
+$sourceId = $input['source_id'] ?? '';
+
+if (empty($orderId)) {
+ jsonResponse(['error' => 'Order ID required'], 400);
+}
+
+$order = db()->fetch(
+ "SELECT * FROM orders WHERE order_id = :id",
+ ['id' => $orderId]
+);
+
+if (!$order) {
+ jsonResponse(['error' => 'Order not found'], 404);
+}
+
+if ($order['payment_status'] === 'paid') {
+ jsonResponse(['error' => 'Order already paid'], 400);
+}
+
+// Demo mode - Square not configured
+if (!isSquareConfigured()) {
+ db()->update('orders',
+ [
+ 'payment_status' => 'paid',
+ 'order_status' => 'confirmed',
+ 'square_payment_id' => 'demo_' . bin2hex(random_bytes(8)),
+ 'payment_method' => 'square',
+ ],
+ 'order_id = :id',
+ ['id' => $orderId]
+ );
+
+ jsonResponse([
+ 'demo_mode' => true,
+ 'message' => 'Payment simulated (Square not configured)',
+ 'redirect' => '/order-confirmation.php?order=' . $orderId
+ ]);
+}
+
+if (empty($sourceId)) {
+ jsonResponse(['error' => 'Card details required'], 400);
+}
+
+try {
+ $result = squareCreatePayment(
+ $sourceId,
+ (float) $order['total'],
+ $orderId,
+ ['note' => 'Order #' . $order['order_number']]
+ );
+ $payment = $result['payment'] ?? [];
+ $status = $payment['status'] ?? '';
+
+ if ($status === 'COMPLETED') {
+ markSquarePaymentResult($payment['id'], $orderId, 'COMPLETED');
+ jsonResponse([
+ 'success' => true,
+ 'redirect' => '/order-confirmation.php?order=' . $orderId
+ ]);
+ }
+
+ // Store the payment id even if not yet completed, so the reconciliation
+ // poll and webhook can find this order by square_payment_id.
+ db()->update('orders',
+ ['square_payment_id' => $payment['id'] ?? null],
+ 'order_id = :id',
+ ['id' => $orderId]
+ );
+
+ jsonResponse(['error' => 'Payment was not completed (status: ' . $status . ')'], 400);
+
+} catch (Exception $e) {
+ error_log('Square payment error: ' . $e->getMessage());
+ jsonResponse(['error' => 'Payment failed: ' . $e->getMessage()], 500);
+}
diff --git a/api/payment-status.php b/api/payment-status.php
index 6bec0c3..9da23e0 100644
--- a/api/payment-status.php
+++ b/api/payment-status.php
@@ -1,157 +1,93 @@
'Method not allowed'], 405);
}
$orderId = $_GET['order_id'] ?? '';
-$sessionId = $_GET['session_id'] ?? '';
-if (empty($orderId) && empty($sessionId)) {
- jsonResponse(['error' => 'Order ID or Session ID required'], 400);
+if (empty($orderId)) {
+ jsonResponse(['error' => 'Order ID required'], 400);
}
-// Get order by ID or session
-if (!empty($orderId)) {
- $order = db()->fetch(
- "SELECT * FROM orders WHERE order_id = :id",
- ['id' => $orderId]
- );
-} else {
- $order = db()->fetch(
- "SELECT * FROM orders WHERE stripe_session_id = :session OR stripe_payment_intent = :session",
- ['session' => $sessionId]
- );
-}
+$order = db()->fetch(
+ "SELECT * FROM orders WHERE order_id = :id",
+ ['id' => $orderId]
+);
if (!$order) {
jsonResponse(['error' => 'Order not found'], 404);
}
-// If already marked as paid, return success
if ($order['payment_status'] === 'paid') {
jsonResponse([
- 'status' => 'complete',
+ 'status' => 'complete',
'payment_status' => 'paid',
- 'order_id' => $order['order_id'],
- 'order_number' => $order['order_number'],
- 'redirect' => '/order-confirmation.php?order=' . $order['order_id']
+ 'order_id' => $order['order_id'],
+ 'order_number' => $order['order_number'],
+ 'redirect' => '/order-confirmation.php?order=' . $order['order_id']
]);
}
-// Check if Stripe is configured
-if (!isStripeConfigured()) {
+if (!isSquareConfigured()) {
jsonResponse([
- 'status' => 'demo_mode',
+ 'status' => 'demo_mode',
'payment_status' => $order['payment_status'],
- 'message' => 'Stripe not configured - running in demo mode'
+ 'message' => 'Square not configured - running in demo mode'
+ ]);
+}
+
+if (empty($order['square_payment_id'])) {
+ jsonResponse([
+ 'status' => 'pending',
+ 'payment_status' => $order['payment_status']
]);
}
try {
- // Check with Stripe
- if (!empty($order['stripe_session_id'])) {
- // Check checkout session status
- $session = stripe()->getCheckoutSession($order['stripe_session_id']);
-
- if ($session['payment_status'] === 'paid') {
- // Update order
- db()->update('orders',
- [
- 'payment_status' => 'paid',
- 'order_status' => 'confirmed',
- 'stripe_payment_intent' => $session['payment_intent'] ?? null
- ],
- 'order_id = :id',
- ['id' => $order['order_id']]
- );
+ $result = squareGetPayment($order['square_payment_id']);
+ $payment = $result['payment'] ?? [];
+ $status = $payment['status'] ?? '';
- // Award loyalty points
- if (!empty($order['customer_id'])) {
- loyalty()->awardPoints(
- $order['customer_id'],
- (float) $order['total'],
- 'Order #' . $order['order_number'],
- $order['order_id']
- );
- }
+ markSquarePaymentResult($order['square_payment_id'], $order['order_id'], $status);
- jsonResponse([
- 'status' => 'complete',
- 'payment_status' => 'paid',
- 'order_id' => $order['order_id'],
- 'order_number' => $order['order_number'],
- 'redirect' => '/order-confirmation.php?order=' . $order['order_id']
- ]);
- }
-
+ $updated = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $order['order_id']]);
+
+ if ($updated['payment_status'] === 'paid') {
jsonResponse([
- 'status' => $session['status'],
- 'payment_status' => $session['payment_status']
- ]);
-
- } elseif (!empty($order['stripe_payment_intent'])) {
- // Check payment intent status
- $paymentIntent = stripe()->getPaymentIntent($order['stripe_payment_intent']);
-
- if ($paymentIntent['status'] === 'succeeded') {
- // Update order
- db()->update('orders',
- [
- 'payment_status' => 'paid',
- 'order_status' => 'confirmed'
- ],
- 'order_id = :id',
- ['id' => $order['order_id']]
- );
-
- // Award loyalty points
- if (!empty($order['customer_id'])) {
- loyalty()->awardPoints(
- $order['customer_id'],
- (float) $order['total'],
- 'Order #' . $order['order_number'],
- $order['order_id']
- );
- }
-
- jsonResponse([
- 'status' => 'complete',
- 'payment_status' => 'paid',
- 'order_id' => $order['order_id'],
- 'order_number' => $order['order_number'],
- 'redirect' => '/order-confirmation.php?order=' . $order['order_id']
- ]);
- }
-
- jsonResponse([
- 'status' => $paymentIntent['status'],
- 'payment_status' => 'pending'
+ 'status' => 'complete',
+ 'payment_status' => 'paid',
+ 'order_id' => $updated['order_id'],
+ 'order_number' => $updated['order_number'],
+ 'redirect' => '/order-confirmation.php?order=' . $updated['order_id']
]);
}
-
- // No Stripe reference found
+
jsonResponse([
- 'status' => 'pending',
- 'payment_status' => $order['payment_status']
+ 'status' => $status,
+ 'payment_status' => $updated['payment_status']
]);
-
+
} catch (Exception $e) {
- error_log('Payment status check error: ' . $e->getMessage());
+ error_log('Square payment status check error: ' . $e->getMessage());
jsonResponse([
- 'status' => 'error',
+ 'status' => 'error',
'payment_status' => $order['payment_status'],
- 'error' => 'Failed to check payment status'
+ 'error' => 'Failed to check payment status'
]);
}
diff --git a/api/webhook.php b/api/webhook.php
index beb9113..02701ee 100644
--- a/api/webhook.php
+++ b/api/webhook.php
@@ -1,107 +1,65 @@
verifyWebhookSignature($payload, $sigHeader, STRIPE_WEBHOOK_SECRET);
- $event = json_decode($payload, true);
- } catch (Exception $e) {
- error_log('Stripe webhook signature verification failed: ' . $e->getMessage());
- http_response_code(400);
- exit();
- }
-} else {
- $event = json_decode($payload, true);
- if (!$event) {
- http_response_code(400);
- exit();
- }
+if (empty(SQUARE_WEBHOOK_SIGNATURE_KEY) || SQUARE_WEBHOOK_SIGNATURE_KEY === 'REPLACE_ME') {
+ error_log('Square webhook signature key not configured - rejecting webhook');
+ http_response_code(400);
+ exit();
+}
+
+if (!verifySquareWebhookSignature($payload, $sigHeader, $notificationUrl, SQUARE_WEBHOOK_SIGNATURE_KEY)) {
+ error_log('Square webhook signature verification failed');
+ http_response_code(400);
+ exit();
+}
+
+$event = json_decode($payload, true);
+if (!$event) {
+ http_response_code(400);
+ exit();
}
$eventType = $event['type'] ?? '';
-$data = $event['data']['object'] ?? [];
switch ($eventType) {
- case 'checkout.session.completed':
- // Stripe Checkout (hosted page) — metadata is on the session
- $orderId = $data['metadata']['order_id'] ?? '';
- $paymentIntentId = $data['payment_intent'] ?? '';
- if ($orderId && ($data['payment_status'] ?? '') === 'paid') {
- db()->update('orders',
- [
- 'payment_status' => 'paid',
- 'order_status' => 'confirmed',
- 'stripe_payment_intent' => $paymentIntentId,
- ],
- 'order_id = :id',
- ['id' => $orderId]
- );
- $order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]);
- if ($order) {
- emailService()->sendOrderConfirmation($order);
- }
+ case 'payment.updated':
+ case 'payment.created':
+ $payment = $event['data']['object']['payment'] ?? [];
+ $paymentId = $payment['id'] ?? '';
+ $status = $payment['status'] ?? '';
+ $orderId = $payment['reference_id'] ?? null;
+ if ($paymentId && $status) {
+ markSquarePaymentResult($paymentId, $orderId, $status);
}
break;
- case 'payment_intent.succeeded':
- // Payment Intent flow (embedded/direct) - skip if already confirmed by checkout.session.completed
- $paymentIntentId = $data['id'] ?? '';
- $orderId = $data['metadata']['order_id'] ?? '';
- if ($orderId && ($data['status'] ?? '') === 'succeeded') {
- $order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]);
- if ($order && $order['order_status'] !== 'confirmed') {
- db()->update('orders',
- [
- 'payment_status' => 'paid',
- 'order_status' => 'confirmed',
- 'stripe_payment_intent' => $paymentIntentId,
- ],
- 'order_id = :id',
- ['id' => $orderId]
- );
- $order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]);
- if ($order) {
- emailService()->sendOrderConfirmation($order);
- }
- }
- }
- break;
-
- case 'payment_intent.payment_failed':
- $orderId = $data['metadata']['order_id'] ?? '';
- if ($orderId) {
- db()->update('orders',
- ['payment_status' => 'failed'],
- 'order_id = :id',
- ['id' => $orderId]
- );
- }
- break;
-
- case 'charge.refunded':
- $paymentIntentId = $data['payment_intent'] ?? '';
- if ($paymentIntentId) {
+ case 'refund.updated':
+ case 'refund.created':
+ $refund = $event['data']['object']['refund'] ?? [];
+ $paymentId = $refund['payment_id'] ?? '';
+ $status = $refund['status'] ?? '';
+ if ($status === 'COMPLETED' && $paymentId) {
db()->update('orders',
[
'payment_status' => 'refunded',
'order_status' => 'refunded',
],
- 'stripe_payment_intent = :pi',
- ['pi' => $paymentIntentId]
+ 'square_payment_id = :pid',
+ ['pid' => $paymentId]
);
}
break;
@@ -109,4 +67,3 @@ switch ($eventType) {
http_response_code(200);
echo json_encode(['received' => true]);
-
diff --git a/db/schema.sql b/db/schema.sql
index a07852f..46b4dd8 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -326,6 +326,8 @@ CREATE TABLE `orders` (
`order_status` enum('pending','confirmed','processing','shipped','delivered','cancelled','refunded') DEFAULT 'pending',
`stripe_session_id` varchar(255) DEFAULT NULL,
`stripe_payment_intent` varchar(255) DEFAULT NULL,
+ `square_payment_id` varchar(255) DEFAULT NULL,
+ `square_order_id` varchar(255) DEFAULT NULL,
`tracking_number` varchar(100) DEFAULT NULL,
`tracking_url` varchar(500) DEFAULT NULL,
`notes` text DEFAULT NULL,
diff --git a/includes/square.php b/includes/square.php
new file mode 100644
index 0000000..6260cec
--- /dev/null
+++ b/includes/square.php
@@ -0,0 +1,143 @@
+ true,
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ 'Authorization: Bearer ' . SQUARE_ACCESS_TOKEN,
+ 'Square-Version: 2024-01-18',
+ ],
+ ];
+ if ($method !== 'GET') {
+ $opts[CURLOPT_CUSTOMREQUEST] = $method;
+ $opts[CURLOPT_POSTFIELDS] = json_encode($body ?: new stdClass());
+ }
+ curl_setopt_array($ch, $opts);
+
+ $resp = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $err = curl_error($ch);
+ curl_close($ch);
+
+ if ($err) {
+ throw new Exception('Square API connection error: ' . $err);
+ }
+
+ $decoded = json_decode($resp ?: '{}', true);
+
+ if (!empty($decoded['errors'])) {
+ $msg = $decoded['errors'][0]['detail'] ?? ($decoded['errors'][0]['code'] ?? 'Unknown Square error');
+ throw new Exception($msg);
+ }
+
+ if ($httpCode >= 400) {
+ throw new Exception('Square API error (HTTP ' . $httpCode . ')');
+ }
+
+ return $decoded;
+}
+
+/**
+ * Create a payment. $sourceId is the nonce from the Web Payments SDK's tokenize() call.
+ */
+function squareCreatePayment(string $sourceId, float $amount, string $referenceId, array $options = []): array {
+ $body = [
+ 'source_id' => $sourceId,
+ 'idempotency_key' => $referenceId . '_' . bin2hex(random_bytes(8)),
+ 'amount_money' => [
+ 'amount' => (int) round($amount * 100),
+ 'currency' => 'USD',
+ ],
+ 'location_id' => SQUARE_LOCATION_ID,
+ 'autocomplete' => true,
+ 'reference_id' => $referenceId,
+ ];
+ if (!empty($options['note'])) {
+ $body['note'] = $options['note'];
+ }
+ return squareApi('POST', '/payments', $body);
+}
+
+function squareGetPayment(string $paymentId): array {
+ return squareApi('GET', '/payments/' . $paymentId);
+}
+
+function isSquareConfigured(): bool {
+ return defined('SQUARE_ACCESS_TOKEN') && defined('SQUARE_APP_ID') && defined('SQUARE_LOCATION_ID')
+ && !empty(SQUARE_ACCESS_TOKEN) && !empty(SQUARE_APP_ID) && !empty(SQUARE_LOCATION_ID)
+ && SQUARE_ACCESS_TOKEN !== 'REPLACE_ME';
+}
+
+/**
+ * Square's webhook signature: base64(HMAC-SHA256(notification_url . raw_body, signature_key)).
+ * The notification URL must exactly match what's configured in the Square Dashboard subscription.
+ */
+function verifySquareWebhookSignature(string $payload, string $signature, string $notificationUrl, string $signatureKey): bool {
+ if (empty($signatureKey) || empty($signature)) {
+ return false;
+ }
+ $expected = base64_encode(hash_hmac('sha256', $notificationUrl . $payload, $signatureKey, true));
+ return hash_equals($expected, $signature);
+}
+
+/**
+ * Single source of truth for "an order's Square payment resolved" — called from the
+ * synchronous create-payment response, the webhook, and the reconciliation poll, so
+ * loyalty points/emails are never awarded or sent more than once regardless of which
+ * path resolves the order first.
+ */
+function markSquarePaymentResult(string $paymentId, ?string $orderId, string $status): void {
+ $order = $orderId
+ ? db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId])
+ : db()->fetch("SELECT * FROM orders WHERE square_payment_id = :pid", ['pid' => $paymentId]);
+
+ if (!$order) {
+ return;
+ }
+
+ if ($status === 'COMPLETED') {
+ if ($order['order_status'] !== 'confirmed') {
+ db()->update('orders',
+ [
+ 'payment_status' => 'paid',
+ 'order_status' => 'confirmed',
+ 'square_payment_id' => $paymentId,
+ 'payment_method' => 'square',
+ ],
+ 'order_id = :id',
+ ['id' => $order['order_id']]
+ );
+
+ if (!empty($order['customer_id'])) {
+ loyalty()->awardPoints(
+ $order['customer_id'],
+ (float) $order['total'],
+ 'Order #' . $order['order_number'],
+ $order['order_id']
+ );
+ }
+
+ $updated = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $order['order_id']]);
+ if ($updated) {
+ emailService()->sendOrderConfirmation($updated);
+ }
+ }
+ } elseif (in_array($status, ['FAILED', 'CANCELED'], true)) {
+ db()->update('orders',
+ ['payment_status' => 'failed'],
+ 'order_id = :id',
+ ['id' => $order['order_id']]
+ );
+ }
+}
diff --git a/payment.php b/payment.php
index 36ed4be..d90f2b1 100644
--- a/payment.php
+++ b/payment.php
@@ -1,13 +1,20 @@
fetch(
"SELECT * FROM orders WHERE order_id = :id",
['id' => $orderId]
@@ -26,16 +32,20 @@ if (!$order) {
redirect('/cart.php');
}
-// If already paid, redirect to confirmation
if ($order['payment_status'] === 'paid') {
clearCart();
redirect('/order-confirmation.php?order=' . $orderId);
}
-$stripePublishableKey = STRIPE_PUBLISHABLE_KEY;
-$stripeConfigured = isStripeConfigured();
$total = $order['total'];
+if ($processor === 'square') {
+ $squareConfigured = isSquareConfigured();
+} else {
+ $stripePublishableKey = STRIPE_PUBLISHABLE_KEY;
+ $stripeConfigured = isStripeConfigured();
+}
+
require_once __DIR__ . '/includes/header.php';
?>
@@ -51,7 +61,7 @@ require_once __DIR__ . '/includes/header.php';
Payment was cancelled. Please try again.
-
+
Order #= htmlspecialchars($order['order_number']) ?>
@@ -61,47 +71,76 @@ require_once __DIR__ . '/includes/header.php';
= htmlspecialchars($order['customer_email']) ?>
-
-
-
-
- Demo Mode: Stripe is not configured. Click below to simulate a successful payment.
-
-
-
-
-
-
-
or enter card details below
-
-
-