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']] ); } }