diff --git a/account/rewards.php b/account/rewards.php index cb7f900..e22fd91 100644 --- a/account/rewards.php +++ b/account/rewards.php @@ -37,6 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['redeem_points'])) { redirect('/account/rewards.php'); } +$extraHead = ''; require_once __DIR__ . '/../includes/header.php'; require_once __DIR__ . '/includes/sidebar.php'; ?> 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', [ - +
- + +
+ +
+
+

Square

+
+
+
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + From the Square Developer Dashboard's webhook subscription for this site +
+ + +
+
+
+ +
-

Stripe

+

Stripe (legacy)

@@ -86,41 +163,41 @@ $methods = getSetting('payment_methods', [ Enable Stripe payments
- +
- +
-
- +
-
- +
- Get this from your Stripe webhook settings
- +
- +
@@ -130,35 +207,35 @@ $methods = getSetting('payment_methods', [

Select which payment methods are available in the Point of Sale system.

- +
- +
- +
- +
- +
diff --git a/api/apply-wallet-credit.php b/api/apply-wallet-credit.php new file mode 100644 index 0000000..aa1073e --- /dev/null +++ b/api/apply-wallet-credit.php @@ -0,0 +1,58 @@ + 'Method not allowed'], 405); +} + +if (!CustomerAuth::isLoggedIn()) { + jsonResponse(['error' => 'Please log in to use wallet balance or a gift card'], 401); +} + +$customer = CustomerAuth::getFullUser(); +$input = json_decode(file_get_contents('php://input'), true); + +$orderTotal = (float) ($input['order_total'] ?? 0); +if ($orderTotal <= 0) { + jsonResponse(['error' => 'Invalid order total'], 400); +} + +$giftCardCode = trim($input['gift_card_code'] ?? ''); + +if (!empty($giftCardCode)) { + $redeemResult = loyalty()->redeemGiftCardToWallet($customer['customer_id'], $giftCardCode); + if (!$redeemResult['success']) { + jsonResponse(['error' => $redeemResult['error']], 400); + } + $walletBalance = $redeemResult['new_balance']; +} else { + $fresh = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customer['customer_id']]); + $walletBalance = (float) ($fresh['wallet_balance'] ?? 0); +} + +$requested = isset($input['amount']) ? (float) $input['amount'] : $walletBalance; +$applyAmount = max(0, round(min($requested, $walletBalance, $orderTotal), 2)); + +jsonResponse([ + 'success' => true, + 'apply_amount' => $applyAmount, + 'wallet_balance' => $walletBalance, + 'new_total' => round($orderTotal - $applyAmount, 2) +]); 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/redeem-gift-card.php b/api/redeem-gift-card.php index c3e6854..0e37830 100644 --- a/api/redeem-gift-card.php +++ b/api/redeem-gift-card.php @@ -7,6 +7,7 @@ header('Content-Type: application/json'); require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/loyalty.php'; if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['error' => 'Method not allowed'], 405); @@ -19,79 +20,15 @@ if (!CustomerAuth::isLoggedIn()) { $customer = CustomerAuth::getFullUser(); $input = json_decode(file_get_contents('php://input'), true); -$code = strtoupper(str_replace(['-', ' '], '', trim($input['code'] ?? ''))); +$result = loyalty()->redeemGiftCardToWallet($customer['customer_id'], $input['code'] ?? ''); -if (empty($code) || strlen($code) < 8) { - jsonResponse(['error' => 'Invalid gift card code'], 400); +if (!$result['success']) { + jsonResponse(['error' => $result['error']], 400); } -// Find gift card -$giftCard = db()->fetch( - "SELECT * FROM gift_cards WHERE code = :code AND is_active = 1", - ['code' => $code] -); - -if (!$giftCard) { - jsonResponse(['error' => 'Gift card not found or already used'], 404); -} - -if ($giftCard['current_balance'] <= 0) { - jsonResponse(['error' => 'This gift card has no remaining balance'], 400); -} - -if ($giftCard['expires_at'] && strtotime($giftCard['expires_at']) < time()) { - jsonResponse(['error' => 'This gift card has expired'], 400); -} - -$amount = $giftCard['current_balance']; - -try { - // Start transaction - db()->query("START TRANSACTION"); - - // Update gift card balance to 0 - db()->query( - "UPDATE gift_cards SET current_balance = 0, is_active = 0, updated_at = NOW() WHERE gift_card_id = :id", - ['id' => $giftCard['gift_card_id']] - ); - - // Log gift card transaction - db()->insert('gift_card_transactions', [ - 'gift_card_id' => $giftCard['gift_card_id'], - 'amount' => -$amount, - 'balance_after' => 0, - 'type' => 'redeem', - 'description' => 'Redeemed by customer: ' . $customer['email'] - ]); - - // Add to customer wallet - $newWalletBalance = ($customer['wallet_balance'] ?? 0) + $amount; - - db()->query( - "UPDATE customers SET wallet_balance = :balance, updated_at = NOW() WHERE customer_id = :id", - ['balance' => $newWalletBalance, 'id' => $customer['customer_id']] - ); - - // Log wallet transaction - db()->insert('wallet_transactions', [ - 'transaction_id' => generateId('wt_'), - 'customer_id' => $customer['customer_id'], - 'amount' => $amount, - 'balance_after' => $newWalletBalance, - 'type' => 'gift_card', - 'description' => 'Gift card redeemed: ' . $code - ]); - - db()->query("COMMIT"); - - jsonResponse([ - 'success' => true, - 'amount' => $amount, - 'new_balance' => $newWalletBalance, - 'message' => formatCurrency($amount) . ' has been added to your wallet!' - ]); - -} catch (Exception $e) { - db()->query("ROLLBACK"); - jsonResponse(['error' => 'Failed to redeem gift card. Please try again.'], 500); -} +jsonResponse([ + 'success' => true, + 'amount' => $result['amount'], + 'new_balance' => $result['new_balance'], + 'message' => formatCurrency($result['amount']) . ' has been added to your wallet!' +]); diff --git a/api/search-customers.php b/api/search-customers.php index e52bbd3..5b60376 100644 --- a/api/search-customers.php +++ b/api/search-customers.php @@ -16,10 +16,10 @@ if (strlen($query) < 2) { $customers = db()->fetchAll( "SELECT customer_id, email, name, phone, wallet_balance, reward_points FROM customers - WHERE (email LIKE :q OR name LIKE :q OR phone LIKE :q) AND is_active = 1 + WHERE (email LIKE :q1 OR name LIKE :q2 OR phone LIKE :q3) AND is_active = 1 ORDER BY name ASC LIMIT 20", - ['q' => '%' . $query . '%'] + ['q1' => '%' . $query . '%', 'q2' => '%' . $query . '%', 'q3' => '%' . $query . '%'] ); jsonResponse($customers); 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/checkout.php b/checkout.php index 5673ed0..e10f0b6 100644 --- a/checkout.php +++ b/checkout.php @@ -6,6 +6,7 @@ $pageTitle = "Checkout - Tom's Java Jive"; require_once __DIR__ . '/includes/functions.php'; require_once __DIR__ . '/includes/auth.php'; +require_once __DIR__ . '/includes/loyalty.php'; $cart = getCart(); if (empty($cart)) { @@ -22,7 +23,7 @@ foreach ($cart as $productId => $quantity) { "SELECT product_id, name, price, sale_price, stock, images FROM products WHERE product_id = :id AND is_active = 1", ['id' => $productId] ); - + if ($product) { $images = json_decode($product['images'] ?? '[]', true); $product['image'] = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg'; @@ -50,10 +51,8 @@ if ($shippingSettings['flat_rate_enabled'] ?? true) { } } -$total = $subtotal + $shippingCost; - -// Get Stripe publishable key -$stripeKey = STRIPE_PUBLISHABLE_KEY; +$preDiscountTotal = $subtotal + $shippingCost; +$processor = defined('PAYMENT_PROCESSOR') ? PAYMENT_PROCESSOR : 'stripe'; $errors = []; @@ -67,19 +66,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $city = trim($_POST['city'] ?? ''); $state = trim($_POST['state'] ?? ''); $zip = trim($_POST['zip'] ?? ''); - + if (empty($email)) $errors['email'] = 'Email is required'; if (empty($name)) $errors['name'] = 'Name is required'; if (empty($address)) $errors['address'] = 'Address is required'; if (empty($city)) $errors['city'] = 'City is required'; if (empty($state)) $errors['state'] = 'State is required'; if (empty($zip)) $errors['zip'] = 'ZIP code is required'; - + + // Re-validate any requested wallet credit server-side against the + // customer's current balance - never trust the client-submitted amount. + $walletAmountUsed = 0; + if ($customer && !empty($_POST['wallet_amount_used'])) { + $requestedWallet = (float) $_POST['wallet_amount_used']; + $freshCustomer = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customer['customer_id']]); + $currentBalance = (float) ($freshCustomer['wallet_balance'] ?? 0); + $walletAmountUsed = max(0, round(min($requestedWallet, $currentBalance, $preDiscountTotal), 2)); + } + + $total = round($preDiscountTotal - $walletAmountUsed, 2); + if (empty($errors)) { // Create order $orderId = generateId('ord_'); $orderNumber = generateOrderNumber(); - + // Get or create customer $customerId = null; if ($customer) { @@ -87,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } else { $customerId = CustomerAuth::createGuest($email, $name, $phone); } - + // Prepare order items $orderItems = []; foreach ($cartItems as $item) { @@ -99,7 +110,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'total' => $item['total'] ]; } - + // Insert order db()->insert('orders', [ 'order_id' => $orderId, @@ -111,6 +122,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'items' => json_encode($orderItems), 'subtotal' => $subtotal, 'shipping_cost' => $shippingCost, + 'wallet_amount_used' => $walletAmountUsed, 'total' => $total, 'shipping_address' => json_encode([ 'address' => $address, @@ -120,11 +132,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'country' => 'USA' ]), 'shipping_method' => 'standard', - 'payment_method' => 'stripe', + 'payment_method' => $processor, 'payment_status' => 'pending', 'order_status' => 'pending' ]); - + // Insert order items for reporting foreach ($orderItems as $item) { db()->insert('order_items', [ @@ -136,7 +148,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'total' => $item['total'] ]); } - + // Reduce stock foreach ($cartItems as $item) { db()->query( @@ -144,10 +156,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ['qty' => $item['quantity'], 'id' => $item['product_id']] ); } - + // Store order ID for payment $_SESSION['pending_order_id'] = $orderId; - + // Redirect to payment page redirect('/payment.php?order=' . $orderId); } @@ -165,10 +177,11 @@ require_once __DIR__ . '/includes/header.php';

Checkout

- + +
- +
@@ -184,76 +197,76 @@ require_once __DIR__ . '/includes/header.php';
-
- +
-
- +
-
- +

Already have an account? Sign in

- +

Shipping Address

- - +
-
- +
-
- +
-
- +
- @@ -262,7 +275,39 @@ require_once __DIR__ . '/includes/header.php';
- + + + +
+
+

Wallet & Gift Cards

+
+
+

+ Wallet balance: +

+ 0): ?> + + +
+
+ + +
+ +
+
+ +
+
+ +
@@ -274,7 +319,7 @@ require_once __DIR__ . '/includes/header.php';
- +
@@ -313,12 +358,16 @@ require_once __DIR__ . '/includes/header.php';
+
Total - +
@@ -339,4 +388,80 @@ require_once __DIR__ . '/includes/header.php';
+ + + + 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/loyalty.php b/includes/loyalty.php index b6c7592..925d43f 100644 --- a/includes/loyalty.php +++ b/includes/loyalty.php @@ -150,10 +150,10 @@ class LoyaltyProgram { db()->query( "UPDATE customers SET reward_points = reward_points + :points, - lifetime_points = COALESCE(lifetime_points, 0) + :points, + lifetime_points = COALESCE(lifetime_points, 0) + :points2, updated_at = NOW() WHERE customer_id = :id", - ['points' => $totalPoints, 'id' => $customerId] + ['points' => $totalPoints, 'points2' => $totalPoints, 'id' => $customerId] ); $newBalance = db()->fetch( @@ -417,6 +417,86 @@ class LoyaltyProgram { 'new_customer_bonus' => $newCustomerBonus ]; } + + /** + * Redeem a gift card code into wallet balance. Shared by the Wallet page + * and checkout so both go through the same transaction logic instead of + * duplicating it. + */ + public function redeemGiftCardToWallet(string $customerId, string $rawCode): array { + $code = strtoupper(str_replace(['-', ' '], '', trim($rawCode))); + + if (empty($code) || strlen($code) < 8) { + return ['success' => false, 'error' => 'Invalid gift card code']; + } + + $giftCard = db()->fetch( + "SELECT * FROM gift_cards WHERE code = :code AND is_active = 1", + ['code' => $code] + ); + + if (!$giftCard) { + return ['success' => false, 'error' => 'Gift card not found or already used']; + } + + if ($giftCard['current_balance'] <= 0) { + return ['success' => false, 'error' => 'This gift card has no remaining balance']; + } + + if ($giftCard['expires_at'] && strtotime($giftCard['expires_at']) < time()) { + return ['success' => false, 'error' => 'This gift card has expired']; + } + + $customer = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customerId]); + if (!$customer) { + return ['success' => false, 'error' => 'Customer not found']; + } + + $amount = $giftCard['current_balance']; + + try { + db()->query("START TRANSACTION"); + + db()->query( + "UPDATE gift_cards SET current_balance = 0, is_active = 0 WHERE gift_card_id = :id", + ['id' => $giftCard['gift_card_id']] + ); + + db()->insert('gift_card_transactions', [ + 'gift_card_id' => $giftCard['gift_card_id'], + 'amount' => -$amount, + 'balance_after' => 0, + 'type' => 'redemption', + ]); + + $newWalletBalance = (float) $customer['wallet_balance'] + $amount; + + db()->query( + "UPDATE customers SET wallet_balance = :balance, updated_at = NOW() WHERE customer_id = :id", + ['balance' => $newWalletBalance, 'id' => $customerId] + ); + + db()->insert('wallet_transactions', [ + 'transaction_id' => generateId('wt_'), + 'customer_id' => $customerId, + 'amount' => $amount, + 'balance_after' => $newWalletBalance, + 'type' => 'deposit', + 'description' => 'Gift card redeemed: ' . $code + ]); + + db()->query("COMMIT"); + + return [ + 'success' => true, + 'amount' => $amount, + 'new_balance' => $newWalletBalance + ]; + } catch (Exception $e) { + db()->query("ROLLBACK"); + return ['success' => false, 'error' => 'Failed to redeem gift card. Please try again.']; + } + } } // Helper function diff --git a/includes/square.php b/includes/square.php new file mode 100644 index 0000000..ec4b330 --- /dev/null +++ b/includes/square.php @@ -0,0 +1,160 @@ + 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'] + ); + } + + if (!empty($order['wallet_amount_used']) && (float) $order['wallet_amount_used'] > 0 && !empty($order['customer_id'])) { + $walletAmount = (float) $order['wallet_amount_used']; + $cust = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $order['customer_id']]); + if ($cust) { + $newBalance = max(0, (float) $cust['wallet_balance'] - $walletAmount); + db()->update('customers', ['wallet_balance' => $newBalance], 'customer_id = :id', ['id' => $order['customer_id']]); + db()->insert('wallet_transactions', [ + 'transaction_id' => generateId('wt_'), + 'customer_id' => $order['customer_id'], + 'amount' => -$walletAmount, + 'balance_after' => $newBalance, + 'type' => 'purchase', + 'description' => 'Applied to Order #' . $order['order_number'], + ]); + } + } + + $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..981fe54 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 # @@ -61,47 +71,100 @@ require_once __DIR__ . '/includes/header.php';

- - - -
- Demo Mode: Stripe is not configured. Click below to simulate a successful payment. -
- - - - - -
- -

or enter card details below

-
- -
-
- -
-
+ + + +
+ Demo Mode: Square is not configured. Click below to simulate a successful payment.
- - - +
+ +
+ +
+ + +
+ +
+
+ +
+
+
+ + +
+ + + + + +
+ Demo Mode: Stripe is not configured. Click below to simulate a successful payment. +
+
+ +
+ +
+ +

or enter card details below

+
+ +
+
+ +
+
+
+ + +
+ - + - +

Your payment is secure and encrypted

@@ -110,9 +173,12 @@ require_once __DIR__ . '/includes/header.php';
+ + + +