false, 'error' => 'Falta el fitxer config.php' ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } /** @var array $config */ $config = require $configFile; return $config; } function kapvoe_json_input(): array { $raw = file_get_contents('php://input') ?: '{}'; $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : []; } function kapvoe_json_response(array $payload, int $status = 200): never { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } function kapvoe_require_post(): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { kapvoe_json_response(['ok' => false, 'error' => 'Mètode no permès'], 405); } } function kapvoe_validate_required(array $data, array $fields): array { $errors = []; foreach ($fields as $field) { if (!isset($data[$field]) || trim((string)$data[$field]) === '') { $errors[$field] = 'Camp obligatori'; } } return $errors; } function kapvoe_orders_csv_path(array $config): string { $dir = rtrim((string)$config['orders_storage_dir'], DIRECTORY_SEPARATOR); if (!is_dir($dir)) { mkdir($dir, 0775, true); } return $dir . DIRECTORY_SEPARATOR . 'orders.csv'; } function kapvoe_append_order(array $config, array $order): void { $path = kapvoe_orders_csv_path($config); $exists = file_exists($path); $fh = fopen($path, 'ab'); if (!$fh) { throw new RuntimeException('No s\'ha pogut obrir orders.csv'); } if (!$exists) { fputcsv($fh, array_keys($order), ';'); } fputcsv($fh, array_values($order), ';'); fclose($fh); } function kapvoe_update_order_status(array $config, string $sessionId, string $newStatus, string $paymentIntent = ''): void { $path = kapvoe_orders_csv_path($config); if (!file_exists($path)) { return; } $rows = []; $fh = fopen($path, 'rb'); if (!$fh) { return; } while (($row = fgetcsv($fh, 0, ';')) !== false) { $rows[] = $row; } fclose($fh); if (count($rows) < 2) { return; } $header = $rows[0]; $sessionIdx = array_search('stripe_session_id', $header, true); $statusIdx = array_search('payment_status', $header, true); $intentIdx = array_search('payment_intent_id', $header, true); if ($sessionIdx === false || $statusIdx === false) { return; } for ($i = 1; $i < count($rows); $i++) { while (count($rows[$i]) < count($header)) { $rows[$i][] = ''; } if (($rows[$i][$sessionIdx] ?? '') === $sessionId) { $rows[$i][$statusIdx] = $newStatus; if ($intentIdx !== false) { $rows[$i][$intentIdx] = $paymentIntent; } break; } } $fh = fopen($path, 'wb'); if (!$fh) { return; } foreach ($rows as $row) { while (count($row) < count($header)) { $row[] = ''; } fputcsv($fh, $row, ';'); } fclose($fh); } function kapvoe_get_order_by_session_id(array $config, string $sessionId): ?array { $path = kapvoe_orders_csv_path($config); if (!file_exists($path)) { return null; } $fh = fopen($path, 'rb'); if (!$fh) { return null; } $header = fgetcsv($fh, 0, ';'); if (!$header) { fclose($fh); return null; } while (($row = fgetcsv($fh, 0, ';')) !== false) { while (count($row) < count($header)) { $row[] = ''; } $assoc = array_combine($header, $row); if (($assoc['stripe_session_id'] ?? '') === $sessionId) { fclose($fh); return $assoc ?: null; } } fclose($fh); return null; } function kapvoe_mark_order_stock_updated(array $config, string $sessionId): void { $path = kapvoe_orders_csv_path($config); if (!file_exists($path)) { return; } $rows = []; $fh = fopen($path, 'rb'); if (!$fh) { return; } while (($row = fgetcsv($fh, 0, ';')) !== false) { $rows[] = $row; } fclose($fh); if (count($rows) < 1) { return; } $header = $rows[0]; $sessionIdx = array_search('stripe_session_id', $header, true); $stockUpdatedIdx = array_search('stock_updated', $header, true); $stockUpdatedAtIdx = array_search('stock_updated_at', $header, true); $webhookProcessedIdx = array_search('webhook_processed_at', $header, true); if ($sessionIdx === false) { return; } if ($stockUpdatedIdx === false) { $header[] = 'stock_updated'; $stockUpdatedIdx = count($header) - 1; } if ($stockUpdatedAtIdx === false) { $header[] = 'stock_updated_at'; $stockUpdatedAtIdx = count($header) - 1; } if ($webhookProcessedIdx === false) { $header[] = 'webhook_processed_at'; $webhookProcessedIdx = count($header) - 1; } $rows[0] = $header; $now = date('Y-m-d H:i:s'); for ($i = 1; $i < count($rows); $i++) { while (count($rows[$i]) < count($header)) { $rows[$i][] = ''; } if (($rows[$i][$sessionIdx] ?? '') === $sessionId) { if (($rows[$i][$stockUpdatedIdx] ?? '') === '1') { return; } $rows[$i][$stockUpdatedIdx] = '1'; $rows[$i][$stockUpdatedAtIdx] = $now; $rows[$i][$webhookProcessedIdx] = $now; break; } } $fh = fopen($path, 'wb'); if (!$fh) { return; } foreach ($rows as $row) { while (count($row) < count($header)) { $row[] = ''; } fputcsv($fh, $row, ';'); } fclose($fh); } function kapvoe_create_checkout_session(array $config, array $payload): array { $secretKey = (string)$config['stripe_secret_key']; $fields = [ 'mode' => 'payment', 'success_url' => (string)$config['success_url'] . '?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => (string)$config['cancel_url'], 'customer_email' => (string)$payload['email'], 'client_reference_id' => (string)$payload['order_id'], 'locale' => 'es', 'payment_method_types[0]' => 'card', 'line_items[0][price_data][currency]' => (string)$config['currency'], 'line_items[0][price_data][product_data][name]' => (string)$payload['product_name'], 'line_items[0][price_data][unit_amount]' => (string)$payload['unit_amount_cents'], 'line_items[0][quantity]' => (string)$payload['quantity'], 'metadata[order_id]' => (string)$payload['order_id'], 'metadata[product_code]' => (string)$payload['product_code'], 'metadata[customer_name]' => (string)$payload['customer_name'], 'metadata[phone]' => (string)$payload['phone'], 'metadata[postal_code]' => (string)$payload['postal_code'], 'metadata[city]' => (string)$payload['city'], 'metadata[province]' => (string)$payload['province'], ]; $ch = curl_init('https://api.stripe.com/v1/checkout/sessions'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($fields), CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $secretKey ], ]); $raw = curl_exec($ch); if ($raw === false) { throw new RuntimeException('Error cURL Stripe: ' . curl_error($ch)); } $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $decoded = json_decode($raw, true); if ($httpCode >= 400 || !is_array($decoded)) { throw new RuntimeException('Stripe ha retornat un error: ' . $raw); } return $decoded; } function kapvoe_decrement_sheet_stock(array $config, array $order, string $sessionId): array { $url = (string)($config['stock_sync_url'] ?? ''); $token = (string)($config['stock_sync_token'] ?? ''); if ($url === '' || $token === '') { throw new RuntimeException('Falta configurar stock_sync_url o stock_sync_token'); } $payload = [ 'action' => 'decrement_stock', 'token' => $token, 'product_code' => (string)($order['product_code'] ?? ''), 'quantity' => max(1, (int)($order['quantity'] ?? 1)), 'order_id' => (string)($order['order_id'] ?? ''), 'session_id' => $sessionId, ]; if ($payload['product_code'] === '') { throw new RuntimeException('La comanda no té product_code'); } if ($payload['order_id'] === '') { throw new RuntimeException('La comanda no té order_id'); } if (!function_exists('curl_init')) { throw new RuntimeException('cURL no està disponible al PHP'); } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', ], CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 10, ]); $response = curl_exec($ch); if ($response === false) { $error = curl_error($ch); curl_close($ch); throw new RuntimeException("Error cURL stock sync: {$error}"); } $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($statusCode < 200 || $statusCode >= 300) { throw new RuntimeException("Stock sync HTTP {$statusCode}: {$response}"); } $json = json_decode($response, true); if (!is_array($json)) { throw new RuntimeException('Resposta no JSON del stock sync: ' . $response); } if (!($json['ok'] ?? false)) { throw new RuntimeException('Stock sync error: ' . ($json['error'] ?? 'error desconegut')); } return $json; } function kapvoe_verify_stripe_signature(string $payload, string $header, string $secret, int $tolerance = 300): bool { if (!$header || !$secret) { return false; } $parts = explode(',', $header); $timestamp = null; $signature = null; foreach ($parts as $part) { [$k, $v] = array_pad(explode('=', trim($part), 2), 2, null); if ($k === 't') { $timestamp = $v; } if ($k === 'v1') { $signature = $v; } } if (!$timestamp || !$signature) { return false; } if (abs(time() - (int)$timestamp) > $tolerance) { return false; } $signedPayload = $timestamp . '.' . $payload; $expected = hash_hmac('sha256', $signedPayload, $secret); return hash_equals($expected, $signature); }