Merge square-migration: Stripe to Square payment processor migration, checkout wallet/gift-card redemption, and related bug fixes

This commit is contained in:
2026-07-05 15:01:30 +00:00
15 changed files with 1039 additions and 654 deletions
+1
View File
@@ -37,6 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['redeem_points'])) {
redirect('/account/rewards.php'); redirect('/account/rewards.php');
} }
$extraHead = '<link rel="stylesheet" href="/assets/css/account.css?v='. filemtime(__DIR__ . '/../assets/css/account.css') .'">';
require_once __DIR__ . '/../includes/header.php'; require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php'; require_once __DIR__ . '/includes/sidebar.php';
?> ?>
+79 -2
View File
@@ -21,6 +21,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
setFlash('success', 'Stripe settings updated'); 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') { if ($section === 'methods') {
setSetting('payment_methods', [ setSetting('payment_methods', [
'card' => isset($_POST['method_card']), 'card' => isset($_POST['method_card']),
@@ -43,6 +55,15 @@ $stripe = getSetting('payment_stripe', [
'webhook_secret' => '' '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', [ $methods = getSetting('payment_methods', [
'card' => true, 'card' => true,
'cash' => true, 'cash' => true,
@@ -72,12 +93,68 @@ $methods = getSetting('payment_methods', [
</div> </div>
<div> <div>
<!-- Stripe Settings --> <!-- Square Settings -->
<form method="POST">
<input type="hidden" name="section" value="square">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title"><i class="fas fa-square" style="color: #006AFF;"></i> Square</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="square_enabled" <?= $square['enabled'] ? 'checked' : '' ?>>
Enable Square payments
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="square_sandbox" <?= $square['sandbox'] ? 'checked' : '' ?>>
Sandbox mode (use sandbox app/location/token)
</label>
</div>
<div class="form-group">
<label class="form-label">Application ID</label>
<input type="text" name="square_app_id" class="form-input"
value="<?= htmlspecialchars($square['app_id']) ?>"
placeholder="sq0idp-...">
</div>
<div class="form-group">
<label class="form-label">Location ID</label>
<input type="text" name="square_location_id" class="form-input"
value="<?= htmlspecialchars($square['location_id']) ?>"
placeholder="L...">
</div>
<div class="form-group">
<label class="form-label">Access Token</label>
<input type="password" name="square_access_token" class="form-input"
value="<?= htmlspecialchars($square['access_token']) ?>"
placeholder="EAAA...">
</div>
<div class="form-group mb-0">
<label class="form-label">Webhook Signature Key</label>
<input type="password" name="square_webhook_signature_key" class="form-input"
value="<?= htmlspecialchars($square['webhook_signature_key']) ?>"
placeholder="...">
<small class="text-muted">From the Square Developer Dashboard's webhook subscription for this site</small>
</div>
<button type="submit" class="btn btn-primary mt-2">Save Square Settings</button>
</div>
</div>
</form>
<!-- Stripe Settings (legacy) -->
<form method="POST"> <form method="POST">
<input type="hidden" name="section" value="stripe"> <input type="hidden" name="section" value="stripe">
<div class="admin-card"> <div class="admin-card">
<div class="admin-card-header"> <div class="admin-card-header">
<h3 class="admin-card-title"><i class="fab fa-stripe" style="color: #635BFF;"></i> Stripe</h3> <h3 class="admin-card-title"><i class="fab fa-stripe" style="color: #635BFF;"></i> Stripe <small class="text-muted">(legacy)</small></h3>
</div> </div>
<div class="admin-card-body"> <div class="admin-card-body">
<div class="form-group"> <div class="form-group">
+58
View File
@@ -0,0 +1,58 @@
<?php
/**
* Tom's Java Jive - Apply Wallet Credit / Gift Card at Checkout
*
* Validates and quotes an amount to apply toward the current order total.
* Does NOT deduct wallet balance here - that only happens once the Square
* payment actually succeeds (see markSquarePaymentResult() in
* includes/square.php), so an abandoned checkout never loses a customer's
* real wallet money. A gift card code, if given, is redeemed into wallet
* balance immediately (same as the Wallet page does) since that step is
* safe/reversible-in-spirit - only the *spending* of wallet balance at
* checkout is deferred to payment success.
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/loyalty.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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)
]);
-129
View File
@@ -1,129 +0,0 @@
<?php
/**
* Tom's Java Jive - Create Stripe Checkout Session API
* Uses hosted checkout page (redirects to Stripe)
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php';
require_once __DIR__ . '/../includes/loyalty.php';
header('Content-Type: application/json');
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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);
}
-87
View File
@@ -1,87 +0,0 @@
<?php
/**
* Tom's Java Jive - Create Stripe Payment Intent API
* Uses cURL-based Stripe integration (no Composer required)
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php';
header('Content-Type: application/json');
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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);
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* Tom's Java Jive - Create Square Payment API
* Accepts a Web Payments SDK card nonce (source_id) and charges it synchronously.
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/square.php';
require_once __DIR__ . '/../includes/loyalty.php';
require_once __DIR__ . '/../includes/email.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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);
}
+38 -102
View File
@@ -1,45 +1,39 @@
<?php <?php
/** /**
* Tom's Java Jive - Check Payment Status API * Tom's Java Jive - Check Payment Status API
* Polls Stripe for payment/checkout session status * Reconciliation-only fallback: Square payments complete synchronously in
* create-square-payment.php, so this endpoint only matters if the browser
* lost that response after Square had already accepted the charge. It shares
* markSquarePaymentResult() with the webhook and the synchronous path so
* loyalty points/emails are never duplicated no matter which path resolves first.
*/ */
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php'; require_once __DIR__ . '/../includes/square.php';
require_once __DIR__ . '/../includes/loyalty.php'; require_once __DIR__ . '/../includes/loyalty.php';
require_once __DIR__ . '/../includes/email.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
// Only accept GET
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['error' => 'Method not allowed'], 405); jsonResponse(['error' => 'Method not allowed'], 405);
} }
$orderId = $_GET['order_id'] ?? ''; $orderId = $_GET['order_id'] ?? '';
$sessionId = $_GET['session_id'] ?? '';
if (empty($orderId) && empty($sessionId)) { if (empty($orderId)) {
jsonResponse(['error' => 'Order ID or Session ID required'], 400); jsonResponse(['error' => 'Order ID required'], 400);
} }
// Get order by ID or session
if (!empty($orderId)) {
$order = db()->fetch( $order = db()->fetch(
"SELECT * FROM orders WHERE order_id = :id", "SELECT * FROM orders WHERE order_id = :id",
['id' => $orderId] ['id' => $orderId]
); );
} else {
$order = db()->fetch(
"SELECT * FROM orders WHERE stripe_session_id = :session OR stripe_payment_intent = :session",
['session' => $sessionId]
);
}
if (!$order) { if (!$order) {
jsonResponse(['error' => 'Order not found'], 404); jsonResponse(['error' => 'Order not found'], 404);
} }
// If already marked as paid, return success
if ($order['payment_status'] === 'paid') { if ($order['payment_status'] === 'paid') {
jsonResponse([ jsonResponse([
'status' => 'complete', 'status' => 'complete',
@@ -50,105 +44,47 @@ if ($order['payment_status'] === 'paid') {
]); ]);
} }
// Check if Stripe is configured if (!isSquareConfigured()) {
if (!isStripeConfigured()) {
jsonResponse([ jsonResponse([
'status' => 'demo_mode', 'status' => 'demo_mode',
'payment_status' => $order['payment_status'], 'payment_status' => $order['payment_status'],
'message' => 'Stripe not configured - running in demo mode' 'message' => 'Square not configured - running in demo mode'
]); ]);
} }
try { if (empty($order['square_payment_id'])) {
// 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']]
);
// 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' => $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'
]);
}
// No Stripe reference found
jsonResponse([ jsonResponse([
'status' => 'pending', 'status' => 'pending',
'payment_status' => $order['payment_status'] 'payment_status' => $order['payment_status']
]); ]);
}
try {
$result = squareGetPayment($order['square_payment_id']);
$payment = $result['payment'] ?? [];
$status = $payment['status'] ?? '';
markSquarePaymentResult($order['square_payment_id'], $order['order_id'], $status);
$updated = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $order['order_id']]);
if ($updated['payment_status'] === 'paid') {
jsonResponse([
'status' => 'complete',
'payment_status' => 'paid',
'order_id' => $updated['order_id'],
'order_number' => $updated['order_number'],
'redirect' => '/order-confirmation.php?order=' . $updated['order_id']
]);
}
jsonResponse([
'status' => $status,
'payment_status' => $updated['payment_status']
]);
} catch (Exception $e) { } catch (Exception $e) {
error_log('Payment status check error: ' . $e->getMessage()); error_log('Square payment status check error: ' . $e->getMessage());
jsonResponse([ jsonResponse([
'status' => 'error', 'status' => 'error',
'payment_status' => $order['payment_status'], 'payment_status' => $order['payment_status'],
+7 -70
View File
@@ -7,6 +7,7 @@ header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/loyalty.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405); jsonResponse(['error' => 'Method not allowed'], 405);
@@ -19,79 +20,15 @@ if (!CustomerAuth::isLoggedIn()) {
$customer = CustomerAuth::getFullUser(); $customer = CustomerAuth::getFullUser();
$input = json_decode(file_get_contents('php://input'), true); $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) { if (!$result['success']) {
jsonResponse(['error' => 'Invalid gift card code'], 400); 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([ jsonResponse([
'success' => true, 'success' => true,
'amount' => $amount, 'amount' => $result['amount'],
'new_balance' => $newWalletBalance, 'new_balance' => $result['new_balance'],
'message' => formatCurrency($amount) . ' has been added to your wallet!' 'message' => formatCurrency($result['amount']) . ' has been added to your wallet!'
]); ]);
} catch (Exception $e) {
db()->query("ROLLBACK");
jsonResponse(['error' => 'Failed to redeem gift card. Please try again.'], 500);
}
+2 -2
View File
@@ -16,10 +16,10 @@ if (strlen($query) < 2) {
$customers = db()->fetchAll( $customers = db()->fetchAll(
"SELECT customer_id, email, name, phone, wallet_balance, reward_points "SELECT customer_id, email, name, phone, wallet_balance, reward_points
FROM customers 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 ORDER BY name ASC
LIMIT 20", LIMIT 20",
['q' => '%' . $query . '%'] ['q1' => '%' . $query . '%', 'q2' => '%' . $query . '%', 'q3' => '%' . $query . '%']
); );
jsonResponse($customers); jsonResponse($customers);
+30 -73
View File
@@ -1,107 +1,65 @@
<?php <?php
/** /**
* Tom's Java Jive - Stripe Webhook Handler * Tom's Java Jive - Square Webhook Handler
* Uses cURL-based Stripe integration (no Composer required)
*/ */
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php'; require_once __DIR__ . '/../includes/square.php';
require_once __DIR__ . '/../includes/loyalty.php';
require_once __DIR__ . '/../includes/email.php'; require_once __DIR__ . '/../includes/email.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
$payload = file_get_contents('php://input'); $payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? ''; $sigHeader = $_SERVER['HTTP_X_SQUARE_HMACSHA256_SIGNATURE'] ?? '';
$notificationUrl = SITE_URL . '/api/webhook.php';
// Verify webhook signature (if secret is configured) if (empty(SQUARE_WEBHOOK_SIGNATURE_KEY) || SQUARE_WEBHOOK_SIGNATURE_KEY === 'REPLACE_ME') {
if (!empty(STRIPE_WEBHOOK_SECRET) && STRIPE_WEBHOOK_SECRET !== 'whsec_your_webhook_secret') { error_log('Square webhook signature key not configured - rejecting webhook');
try {
stripe()->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); http_response_code(400);
exit(); exit();
} }
} else {
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); $event = json_decode($payload, true);
if (!$event) { if (!$event) {
http_response_code(400); http_response_code(400);
exit(); exit();
} }
}
$eventType = $event['type'] ?? ''; $eventType = $event['type'] ?? '';
$data = $event['data']['object'] ?? [];
switch ($eventType) { switch ($eventType) {
case 'checkout.session.completed': case 'payment.updated':
// Stripe Checkout (hosted page) — metadata is on the session case 'payment.created':
$orderId = $data['metadata']['order_id'] ?? ''; $payment = $event['data']['object']['payment'] ?? [];
$paymentIntentId = $data['payment_intent'] ?? ''; $paymentId = $payment['id'] ?? '';
if ($orderId && ($data['payment_status'] ?? '') === 'paid') { $status = $payment['status'] ?? '';
db()->update('orders', $orderId = $payment['reference_id'] ?? null;
[ if ($paymentId && $status) {
'payment_status' => 'paid', markSquarePaymentResult($paymentId, $orderId, $status);
'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; break;
case 'payment_intent.succeeded': case 'refund.updated':
// Payment Intent flow (embedded/direct) - skip if already confirmed by checkout.session.completed case 'refund.created':
$paymentIntentId = $data['id'] ?? ''; $refund = $event['data']['object']['refund'] ?? [];
$orderId = $data['metadata']['order_id'] ?? ''; $paymentId = $refund['payment_id'] ?? '';
if ($orderId && ($data['status'] ?? '') === 'succeeded') { $status = $refund['status'] ?? '';
$order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]); if ($status === 'COMPLETED' && $paymentId) {
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) {
db()->update('orders', db()->update('orders',
[ [
'payment_status' => 'refunded', 'payment_status' => 'refunded',
'order_status' => 'refunded', 'order_status' => 'refunded',
], ],
'stripe_payment_intent = :pi', 'square_payment_id = :pid',
['pi' => $paymentIntentId] ['pid' => $paymentId]
); );
} }
break; break;
@@ -109,4 +67,3 @@ switch ($eventType) {
http_response_code(200); http_response_code(200);
echo json_encode(['received' => true]); echo json_encode(['received' => true]);
+132 -7
View File
@@ -6,6 +6,7 @@
$pageTitle = "Checkout - Tom's Java Jive"; $pageTitle = "Checkout - Tom's Java Jive";
require_once __DIR__ . '/includes/functions.php'; require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/loyalty.php';
$cart = getCart(); $cart = getCart();
if (empty($cart)) { if (empty($cart)) {
@@ -50,10 +51,8 @@ if ($shippingSettings['flat_rate_enabled'] ?? true) {
} }
} }
$total = $subtotal + $shippingCost; $preDiscountTotal = $subtotal + $shippingCost;
$processor = defined('PAYMENT_PROCESSOR') ? PAYMENT_PROCESSOR : 'stripe';
// Get Stripe publishable key
$stripeKey = STRIPE_PUBLISHABLE_KEY;
$errors = []; $errors = [];
@@ -75,6 +74,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($state)) $errors['state'] = 'State is required'; if (empty($state)) $errors['state'] = 'State is required';
if (empty($zip)) $errors['zip'] = 'ZIP code 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)) { if (empty($errors)) {
// Create order // Create order
$orderId = generateId('ord_'); $orderId = generateId('ord_');
@@ -111,6 +122,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'items' => json_encode($orderItems), 'items' => json_encode($orderItems),
'subtotal' => $subtotal, 'subtotal' => $subtotal,
'shipping_cost' => $shippingCost, 'shipping_cost' => $shippingCost,
'wallet_amount_used' => $walletAmountUsed,
'total' => $total, 'total' => $total,
'shipping_address' => json_encode([ 'shipping_address' => json_encode([
'address' => $address, 'address' => $address,
@@ -120,7 +132,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'country' => 'USA' 'country' => 'USA'
]), ]),
'shipping_method' => 'standard', 'shipping_method' => 'standard',
'payment_method' => 'stripe', 'payment_method' => $processor,
'payment_status' => 'pending', 'payment_status' => 'pending',
'order_status' => 'pending' 'order_status' => 'pending'
]); ]);
@@ -167,6 +179,7 @@ require_once __DIR__ . '/includes/header.php';
<h1 style="margin-bottom: 2rem;">Checkout</h1> <h1 style="margin-bottom: 2rem;">Checkout</h1>
<form method="POST" action="" id="checkout-form"> <form method="POST" action="" id="checkout-form">
<input type="hidden" name="wallet_amount_used" id="wallet_amount_used" value="0">
<div class="checkout-layout"> <div class="checkout-layout">
<!-- Customer & Shipping Info --> <!-- Customer & Shipping Info -->
@@ -263,6 +276,38 @@ require_once __DIR__ . '/includes/header.php';
</div> </div>
</div> </div>
<?php if ($customer): ?>
<!-- Wallet / Gift Card -->
<div class="card mb-2">
<div class="card-header">
<h3 style="margin: 0;">Wallet &amp; Gift Cards</h3>
</div>
<div class="card-body">
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem;">
Wallet balance: <strong id="wallet-balance-display"><?= formatCurrency($customer['wallet_balance'] ?? 0) ?></strong>
</p>
<?php if (($customer['wallet_balance'] ?? 0) > 0): ?>
<button type="button" id="apply-wallet-btn" class="btn btn-secondary mb-1">
<i class="fas fa-wallet"></i> Apply Wallet Balance
</button>
<?php endif; ?>
<div class="form-group" style="display: flex; gap: 0.5rem; align-items: flex-end;">
<div style="flex: 1;">
<label class="form-label">Gift Card Code</label>
<input type="text" id="gift-card-code" class="form-input"
placeholder="XXXX-XXXX-XXXX" style="text-transform: uppercase;">
</div>
<button type="button" id="apply-gift-card-btn" class="btn btn-secondary">Apply</button>
</div>
<div id="wallet-message" style="margin-top: 0.5rem;"></div>
<div id="wallet-applied-row" style="display: none; margin-top: 0.5rem; font-weight: 600;">
Applied: <span id="wallet-applied-amount"></span>
<button type="button" id="remove-wallet-btn" class="btn btn-secondary" style="margin-left: 0.5rem; padding: 0.15rem 0.6rem; font-size: 0.8rem;">Remove</button>
</div>
</div>
</div>
<?php endif; ?>
<!-- Order Notes --> <!-- Order Notes -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -313,12 +358,16 @@ require_once __DIR__ . '/includes/header.php';
<?php endif; ?> <?php endif; ?>
</span> </span>
</div> </div>
<div class="checkout-summary-row" id="wallet-discount-row" style="display: none;">
<span>Wallet / Gift Card</span>
<span id="wallet-discount-amount" class="text-success"></span>
</div>
<hr style="margin: 1rem 0;"> <hr style="margin: 1rem 0;">
<div class="checkout-summary-total"> <div class="checkout-summary-total">
<span>Total</span> <span>Total</span>
<span><?= formatCurrency($total) ?></span> <span id="checkout-total-display"><?= formatCurrency($preDiscountTotal) ?></span>
</div> </div>
<button type="submit" class="btn btn-primary btn-lg btn-block mt-2"> <button type="submit" class="btn btn-primary btn-lg btn-block mt-2">
@@ -330,7 +379,7 @@ require_once __DIR__ . '/includes/header.php';
</a> </a>
<p class="secure-badge"> <p class="secure-badge">
<i class="fas fa-lock"></i> Secure checkout powered by Stripe <i class="fas fa-lock"></i> Secure checkout powered by <?= $processor === 'square' ? 'Square' : 'Stripe' ?>
</p> </p>
</div> </div>
</div> </div>
@@ -339,4 +388,80 @@ require_once __DIR__ . '/includes/header.php';
</div> </div>
</section> </section>
<?php if ($customer): ?>
<script>
const ORDER_TOTAL_PRE_DISCOUNT = <?= json_encode($preDiscountTotal) ?>;
const CURRENCY_SYMBOL = <?= json_encode(defined('TJJ_CURRENCY_SYMBOL') ? TJJ_CURRENCY_SYMBOL : '$') ?>;
function fmt(amount) {
return CURRENCY_SYMBOL + Number(amount).toFixed(2);
}
function showWalletMessage(msg, isError) {
const el = document.getElementById('wallet-message');
el.textContent = msg;
el.style.color = isError ? 'var(--color-error)' : 'var(--color-success)';
}
function applyWalletResult(data) {
document.getElementById('wallet_amount_used').value = data.apply_amount;
document.getElementById('checkout-total-display').textContent = fmt(data.new_total);
document.getElementById('wallet-balance-display').textContent = fmt(data.wallet_balance);
if (data.apply_amount > 0) {
document.getElementById('wallet-discount-row').style.display = '';
document.getElementById('wallet-discount-amount').textContent = '-' + fmt(data.apply_amount);
document.getElementById('wallet-applied-row').style.display = '';
document.getElementById('wallet-applied-amount').textContent = fmt(data.apply_amount);
} else {
document.getElementById('wallet-discount-row').style.display = 'none';
document.getElementById('wallet-applied-row').style.display = 'none';
}
}
async function applyCredit(body) {
try {
const response = await fetch('/api/apply-wallet-credit.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ order_total: ORDER_TOTAL_PRE_DISCOUNT }, body))
});
const data = await response.json();
if (data.error) {
showWalletMessage(data.error, true);
return;
}
showWalletMessage('Applied ' + fmt(data.apply_amount) + ' to your order.', false);
applyWalletResult(data);
} catch (err) {
showWalletMessage('Failed to apply credit. Please try again.', true);
}
}
const applyWalletBtn = document.getElementById('apply-wallet-btn');
if (applyWalletBtn) {
applyWalletBtn.addEventListener('click', function() {
applyCredit({});
});
}
document.getElementById('apply-gift-card-btn').addEventListener('click', function() {
const code = document.getElementById('gift-card-code').value.trim();
if (!code) {
showWalletMessage('Enter a gift card code first.', true);
return;
}
applyCredit({ gift_card_code: code });
});
document.getElementById('remove-wallet-btn').addEventListener('click', function() {
document.getElementById('wallet_amount_used').value = 0;
document.getElementById('checkout-total-display').textContent = fmt(ORDER_TOTAL_PRE_DISCOUNT);
document.getElementById('wallet-discount-row').style.display = 'none';
document.getElementById('wallet-applied-row').style.display = 'none';
showWalletMessage('', false);
});
</script>
<?php endif; ?>
<?php require_once __DIR__ . '/includes/footer.php'; ?> <?php require_once __DIR__ . '/includes/footer.php'; ?>
+2
View File
@@ -326,6 +326,8 @@ CREATE TABLE `orders` (
`order_status` enum('pending','confirmed','processing','shipped','delivered','cancelled','refunded') DEFAULT 'pending', `order_status` enum('pending','confirmed','processing','shipped','delivered','cancelled','refunded') DEFAULT 'pending',
`stripe_session_id` varchar(255) DEFAULT NULL, `stripe_session_id` varchar(255) DEFAULT NULL,
`stripe_payment_intent` 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_number` varchar(100) DEFAULT NULL,
`tracking_url` varchar(500) DEFAULT NULL, `tracking_url` varchar(500) DEFAULT NULL,
`notes` text DEFAULT NULL, `notes` text DEFAULT NULL,
+82 -2
View File
@@ -150,10 +150,10 @@ class LoyaltyProgram {
db()->query( db()->query(
"UPDATE customers SET "UPDATE customers SET
reward_points = reward_points + :points, reward_points = reward_points + :points,
lifetime_points = COALESCE(lifetime_points, 0) + :points, lifetime_points = COALESCE(lifetime_points, 0) + :points2,
updated_at = NOW() updated_at = NOW()
WHERE customer_id = :id", WHERE customer_id = :id",
['points' => $totalPoints, 'id' => $customerId] ['points' => $totalPoints, 'points2' => $totalPoints, 'id' => $customerId]
); );
$newBalance = db()->fetch( $newBalance = db()->fetch(
@@ -417,6 +417,86 @@ class LoyaltyProgram {
'new_customer_bonus' => $newCustomerBonus '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 // Helper function
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* Tom's Java Jive - Square Integration (cURL-based, no SDK required)
*/
function squareApi(string $method, string $path, array $body = []): array {
$base = (defined('SQUARE_ENV') && SQUARE_ENV === 'sandbox')
? 'https://connect.squareupsandbox.com/v2'
: 'https://connect.squareup.com/v2';
$ch = curl_init($base . $path);
$opts = [
CURLOPT_RETURNTRANSFER => 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']]
);
}
}
+224 -50
View File
@@ -1,13 +1,20 @@
<?php <?php
/** /**
* Tom's Java Jive - Payment Page (Stripe) * Tom's Java Jive - Payment Page
* Supports both PaymentIntent (card element) and Checkout Session (redirect) flows * Supports Stripe (legacy) or Square, gated by the PAYMENT_PROCESSOR constant.
*/ */
$pageTitle = "Payment - Tom's Java Jive"; $pageTitle = "Payment - Tom's Java Jive";
require_once __DIR__ . '/includes/functions.php'; require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
$processor = defined('PAYMENT_PROCESSOR') ? PAYMENT_PROCESSOR : 'stripe';
if ($processor === 'square') {
require_once __DIR__ . '/includes/square.php';
} else {
require_once __DIR__ . '/includes/stripe.php'; require_once __DIR__ . '/includes/stripe.php';
}
$orderId = $_GET['order'] ?? $_SESSION['pending_order_id'] ?? ''; $orderId = $_GET['order'] ?? $_SESSION['pending_order_id'] ?? '';
$cancelled = isset($_GET['cancelled']); $cancelled = isset($_GET['cancelled']);
@@ -16,7 +23,6 @@ if (empty($orderId)) {
redirect('/cart.php'); redirect('/cart.php');
} }
// Get order
$order = db()->fetch( $order = db()->fetch(
"SELECT * FROM orders WHERE order_id = :id", "SELECT * FROM orders WHERE order_id = :id",
['id' => $orderId] ['id' => $orderId]
@@ -26,15 +32,19 @@ if (!$order) {
redirect('/cart.php'); redirect('/cart.php');
} }
// If already paid, redirect to confirmation
if ($order['payment_status'] === 'paid') { if ($order['payment_status'] === 'paid') {
clearCart(); clearCart();
redirect('/order-confirmation.php?order=' . $orderId); redirect('/order-confirmation.php?order=' . $orderId);
} }
$total = $order['total'];
if ($processor === 'square') {
$squareConfigured = isSquareConfigured();
} else {
$stripePublishableKey = STRIPE_PUBLISHABLE_KEY; $stripePublishableKey = STRIPE_PUBLISHABLE_KEY;
$stripeConfigured = isStripeConfigured(); $stripeConfigured = isStripeConfigured();
$total = $order['total']; }
require_once __DIR__ . '/includes/header.php'; require_once __DIR__ . '/includes/header.php';
?> ?>
@@ -62,8 +72,61 @@ require_once __DIR__ . '/includes/header.php';
</p> </p>
</div> </div>
<?php if ($processor === 'square'): ?>
<?php if (!$squareConfigured): ?>
<div class="alert alert-info mb-2">
<i class="fas fa-info-circle"></i> <strong>Demo Mode:</strong> Square is not configured. Click below to simulate a successful payment.
</div>
<form id="demo-payment-form">
<button type="submit" id="demo-submit" class="btn btn-primary btn-lg btn-block">
<span id="demo-text">Complete Demo Payment <?= formatCurrency($total) ?></span>
<span id="demo-spinner" style="display: none;">
<span class="loading"></span> Processing...
</span>
</button>
</form>
<?php else: ?>
<div class="payment-options mb-2" style="display: flex; gap: 0.5rem;">
<button type="button" id="pay-tab-card" class="btn btn-primary" style="flex: 1;">
<i class="fas fa-credit-card"></i> Card
</button>
<button type="button" id="pay-tab-giftcard" class="btn btn-secondary" style="flex: 1;">
<i class="fas fa-gift"></i> Gift Card
</button>
</div>
<form id="payment-form">
<div class="form-group">
<label class="form-label">Card Details</label>
<div id="card-element" style="padding: 0.75rem; border: 1px solid var(--color-border); border-radius: var(--radius-md);"></div>
<div id="card-errors" class="form-error" style="margin-top: 0.5rem;"></div>
</div>
<button type="submit" id="submit-button" class="btn btn-secondary btn-lg btn-block">
<span id="button-text">Pay <?= formatCurrency($total) ?></span>
<span id="spinner" style="display: none;">
<span class="loading"></span> Processing...
</span>
</button>
</form>
<form id="gift-card-payment-form" style="display: none;">
<div class="form-group">
<label class="form-label">Square Gift Card</label>
<div id="gift-card-element" style="padding: 0.75rem; border: 1px solid var(--color-border); border-radius: var(--radius-md);"></div>
<div id="gift-card-errors" class="form-error" style="margin-top: 0.5rem;"></div>
</div>
<button type="submit" id="gift-card-submit-button" class="btn btn-secondary btn-lg btn-block">
<span id="gift-card-button-text">Pay <?= formatCurrency($total) ?></span>
<span id="gift-card-spinner" style="display: none;">
<span class="loading"></span> Processing...
</span>
</button>
</form>
<?php endif; ?>
<?php else: ?>
<?php if (!$stripeConfigured): ?> <?php if (!$stripeConfigured): ?>
<!-- Demo Mode - No Stripe Keys -->
<div class="alert alert-info mb-2"> <div class="alert alert-info mb-2">
<i class="fas fa-info-circle"></i> <strong>Demo Mode:</strong> Stripe is not configured. Click below to simulate a successful payment. <i class="fas fa-info-circle"></i> <strong>Demo Mode:</strong> Stripe is not configured. Click below to simulate a successful payment.
</div> </div>
@@ -76,7 +139,6 @@ require_once __DIR__ . '/includes/header.php';
</button> </button>
</form> </form>
<?php else: ?> <?php else: ?>
<!-- Stripe Payment Options -->
<div class="payment-options mb-2"> <div class="payment-options mb-2">
<button type="button" id="checkout-btn" class="btn btn-primary btn-lg btn-block mb-1"> <button type="button" id="checkout-btn" class="btn btn-primary btn-lg btn-block mb-1">
<i class="fas fa-credit-card"></i> Pay with Stripe Checkout <i class="fas fa-credit-card"></i> Pay with Stripe Checkout
@@ -99,6 +161,7 @@ require_once __DIR__ . '/includes/header.php';
</button> </button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php endif; ?>
<div id="payment-message" style="display: none; margin-top: 1rem;"></div> <div id="payment-message" style="display: none; margin-top: 1rem;"></div>
@@ -110,9 +173,12 @@ require_once __DIR__ . '/includes/header.php';
</div> </div>
</section> </section>
<?php if ($processor === 'square' && $squareConfigured): ?>
<script src="<?= (defined('SQUARE_ENV') && SQUARE_ENV === 'sandbox') ? 'https://sandbox.web.squarecdn.com/v1/square.js' : 'https://web.squarecdn.com/v1/square.js' ?>"></script>
<?php endif; ?>
<script> <script>
const orderId = '<?= $orderId ?>'; const orderId = '<?= $orderId ?>';
const stripeConfigured = <?= $stripeConfigured ? 'true' : 'false' ?>;
const messageEl = document.getElementById('payment-message'); const messageEl = document.getElementById('payment-message');
function showMessage(message, type = 'info') { function showMessage(message, type = 'info') {
@@ -121,29 +187,23 @@ function showMessage(message, type = 'info') {
messageEl.innerHTML = `<i class="fas fa-${type === 'error' ? 'exclamation-circle' : 'check-circle'}"></i> ${message}`; messageEl.innerHTML = `<i class="fas fa-${type === 'error' ? 'exclamation-circle' : 'check-circle'}"></i> ${message}`;
} }
<?php if (!$stripeConfigured): ?> function wireDemoForm(endpoint) {
// Demo mode payment
const demoForm = document.getElementById('demo-payment-form'); const demoForm = document.getElementById('demo-payment-form');
demoForm.addEventListener('submit', async function(e) { demoForm.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const btn = document.getElementById('demo-submit'); const btn = document.getElementById('demo-submit');
const text = document.getElementById('demo-text'); const text = document.getElementById('demo-text');
const spinner = document.getElementById('demo-spinner'); const spinner = document.getElementById('demo-spinner');
btn.disabled = true; btn.disabled = true;
text.style.display = 'none'; text.style.display = 'none';
spinner.style.display = 'inline'; spinner.style.display = 'inline';
try { try {
const response = await fetch('/api/create-payment-intent.php', { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId }) body: JSON.stringify({ order_id: orderId })
}); });
const data = await response.json(); const data = await response.json();
if (data.demo_mode && data.redirect) { if (data.demo_mode && data.redirect) {
showMessage('Payment simulated successfully! Redirecting...', 'success'); showMessage('Payment simulated successfully! Redirecting...', 'success');
setTimeout(() => window.location.href = data.redirect, 1000); setTimeout(() => window.location.href = data.redirect, 1000);
@@ -160,49 +220,172 @@ demoForm.addEventListener('submit', async function(e) {
spinner.style.display = 'none'; spinner.style.display = 'none';
} }
}); });
}
<?php if ($processor === 'square'): ?>
<?php if (!$squareConfigured): ?>
wireDemoForm('/api/create-square-payment.php');
<?php else: ?>
// Square Web Payments SDK
let squareCard, squareGiftCard;
const squarePayments = window.Square.payments('<?= SQUARE_APP_ID ?>', '<?= SQUARE_LOCATION_ID ?>');
(async function initSquare() {
squareCard = await squarePayments.card();
await squareCard.attach('#card-element');
})();
const cardTab = document.getElementById('pay-tab-card');
const giftCardTab = document.getElementById('pay-tab-giftcard');
const cardForm = document.getElementById('payment-form');
const giftCardForm = document.getElementById('gift-card-payment-form');
let giftCardInitialized = false;
cardTab.addEventListener('click', function() {
cardTab.className = 'btn btn-primary';
giftCardTab.className = 'btn btn-secondary';
cardForm.style.display = '';
giftCardForm.style.display = 'none';
});
giftCardTab.addEventListener('click', async function() {
cardTab.className = 'btn btn-secondary';
giftCardTab.className = 'btn btn-primary';
cardForm.style.display = 'none';
giftCardForm.style.display = '';
if (!giftCardInitialized) {
giftCardInitialized = true;
squareGiftCard = await squarePayments.giftCard();
await squareGiftCard.attach('#gift-card-element');
}
});
giftCardForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('gift-card-submit-button');
const buttonText = document.getElementById('gift-card-button-text');
const spinner = document.getElementById('gift-card-spinner');
submitBtn.disabled = true;
buttonText.style.display = 'none';
spinner.style.display = 'inline';
document.getElementById('gift-card-errors').textContent = '';
try {
const tokenResult = await squareGiftCard.tokenize();
if (tokenResult.status !== 'OK') {
const msg = (tokenResult.errors && tokenResult.errors[0] && tokenResult.errors[0].message) || 'Gift card validation failed';
document.getElementById('gift-card-errors').textContent = msg;
submitBtn.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
return;
}
const response = await fetch('/api/create-square-payment.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId, source_id: tokenResult.token })
});
const data = await response.json();
if (data.error) {
showMessage(data.error, 'error');
submitBtn.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
return;
}
if (data.success && data.redirect) {
showMessage('Payment successful! Redirecting...', 'success');
setTimeout(() => window.location.href = data.redirect, 1000);
}
} catch (err) {
showMessage('Payment failed. Please try again.', 'error');
console.error(err);
submitBtn.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
}
});
const form = document.getElementById('payment-form');
const submitButton = document.getElementById('submit-button');
const buttonText = document.getElementById('button-text');
const spinner = document.getElementById('spinner');
form.addEventListener('submit', async function(e) {
e.preventDefault();
submitButton.disabled = true;
buttonText.style.display = 'none';
spinner.style.display = 'inline';
document.getElementById('card-errors').textContent = '';
try {
const tokenResult = await squareCard.tokenize();
if (tokenResult.status !== 'OK') {
const msg = (tokenResult.errors && tokenResult.errors[0] && tokenResult.errors[0].message) || 'Card validation failed';
document.getElementById('card-errors').textContent = msg;
submitButton.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
return;
}
const response = await fetch('/api/create-square-payment.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId, source_id: tokenResult.token })
});
const data = await response.json();
if (data.demo_mode && data.redirect) {
window.location.href = data.redirect;
return;
}
if (data.error) {
showMessage(data.error, 'error');
submitButton.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
return;
}
if (data.success && data.redirect) {
showMessage('Payment successful! Redirecting...', 'success');
setTimeout(() => window.location.href = data.redirect, 1000);
}
} catch (err) {
showMessage('Payment failed. Please try again.', 'error');
console.error(err);
submitButton.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
}
});
<?php endif; ?>
<?php else: ?>
<?php if (!$stripeConfigured): ?>
wireDemoForm('/api/create-payment-intent.php');
<?php else: ?> <?php else: ?>
// Stripe initialized
const stripe = Stripe('<?= $stripePublishableKey ?>'); const stripe = Stripe('<?= $stripePublishableKey ?>');
const elements = stripe.elements(); const elements = stripe.elements();
const cardElement = elements.create('card', { const cardElement = elements.create('card', {
style: { style: { base: { fontSize: '16px', color: '#1B1B1B', '::placeholder': { color: '#9CA3AF' } } }
base: {
fontSize: '16px',
color: '#1B1B1B',
'::placeholder': { color: '#9CA3AF' }
}
}
}); });
cardElement.mount('#card-element'); cardElement.mount('#card-element');
// Handle validation errors
cardElement.on('change', function(event) { cardElement.on('change', function(event) {
const displayError = document.getElementById('card-errors'); const displayError = document.getElementById('card-errors');
if (event.error) { displayError.textContent = event.error ? event.error.message : '';
displayError.textContent = event.error.message;
} else {
displayError.textContent = '';
}
}); });
// Stripe Checkout button (redirect to hosted page)
document.getElementById('checkout-btn').addEventListener('click', async function() { document.getElementById('checkout-btn').addEventListener('click', async function() {
this.disabled = true; this.disabled = true;
this.innerHTML = '<span class="loading"></span> Redirecting to Stripe...'; this.innerHTML = '<span class="loading"></span> Redirecting to Stripe...';
try { try {
const response = await fetch('/api/create-checkout-session.php', { const response = await fetch('/api/create-checkout-session.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ order_id: orderId, origin_url: window.location.origin })
order_id: orderId,
origin_url: window.location.origin
})
}); });
const data = await response.json(); const data = await response.json();
if (data.demo_mode && data.redirect) { if (data.demo_mode && data.redirect) {
window.location.href = data.redirect; window.location.href = data.redirect;
} else if (data.url) { } else if (data.url) {
@@ -219,7 +402,6 @@ document.getElementById('checkout-btn').addEventListener('click', async function
} }
}); });
// PaymentIntent form (inline card element)
const form = document.getElementById('payment-form'); const form = document.getElementById('payment-form');
const submitButton = document.getElementById('submit-button'); const submitButton = document.getElementById('submit-button');
const buttonText = document.getElementById('button-text'); const buttonText = document.getElementById('button-text');
@@ -227,25 +409,20 @@ const spinner = document.getElementById('spinner');
form.addEventListener('submit', async function(e) { form.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
submitButton.disabled = true; submitButton.disabled = true;
buttonText.style.display = 'none'; buttonText.style.display = 'none';
spinner.style.display = 'inline'; spinner.style.display = 'inline';
try { try {
const response = await fetch('/api/create-payment-intent.php', { const response = await fetch('/api/create-payment-intent.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId }) body: JSON.stringify({ order_id: orderId })
}); });
const data = await response.json(); const data = await response.json();
if (data.demo_mode && data.redirect) { if (data.demo_mode && data.redirect) {
window.location.href = data.redirect; window.location.href = data.redirect;
return; return;
} }
if (data.error) { if (data.error) {
showMessage(data.error, 'error'); showMessage(data.error, 'error');
submitButton.disabled = false; submitButton.disabled = false;
@@ -253,8 +430,6 @@ form.addEventListener('submit', async function(e) {
spinner.style.display = 'none'; spinner.style.display = 'none';
return; return;
} }
// Confirm payment with Stripe
const { error, paymentIntent } = await stripe.confirmCardPayment(data.client_secret, { const { error, paymentIntent } = await stripe.confirmCardPayment(data.client_secret, {
payment_method: { payment_method: {
card: cardElement, card: cardElement,
@@ -264,14 +439,12 @@ form.addEventListener('submit', async function(e) {
} }
} }
}); });
if (error) { if (error) {
showMessage(error.message, 'error'); showMessage(error.message, 'error');
} else if (paymentIntent.status === 'succeeded') { } else if (paymentIntent.status === 'succeeded') {
showMessage('Payment successful! Redirecting...', 'success'); showMessage('Payment successful! Redirecting...', 'success');
setTimeout(() => window.location.href = '/order-confirmation.php?order=' + orderId, 1000); setTimeout(() => window.location.href = '/order-confirmation.php?order=' + orderId, 1000);
} }
} catch (err) { } catch (err) {
showMessage('Payment failed. Please try again.', 'error'); showMessage('Payment failed. Please try again.', 'error');
console.error(err); console.error(err);
@@ -282,6 +455,7 @@ form.addEventListener('submit', async function(e) {
} }
}); });
<?php endif; ?> <?php endif; ?>
<?php endif; ?>
</script> </script>
<?php require_once __DIR__ . '/includes/footer.php'; ?> <?php require_once __DIR__ . '/includes/footer.php'; ?>