Primer commit

This commit is contained in:
2026-04-07 23:30:33 +02:00
commit 618efcd05f
40 changed files with 1547 additions and 0 deletions
+329
View File
@@ -0,0 +1,329 @@
<?php
declare(strict_types=1);
/**
* KAPVOE Stripe Checkout helpers
* Plain PHP + cURL. No Composer dependency required.
*/
function kapvoe_load_config(): array {
$configFile = __DIR__ . '/config.php';
if (!file_exists($configFile)) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => 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_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);
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
return [
// Clau secreta real de Stripe. NO la posis mai al HTML.
'stripe_secret_key' => 'sk_test_51IvGaFHRV8rA2XwsDiGII0rVfot2HabnvF862L8G6rYIzUscZDMkM4WlVAV6GbYSMZsFEDF44PEppdJaLdGo0idB00UyabGh0u',
// Secret del webhook de Stripe (Signing secret)
'stripe_webhook_secret' => 'whsec_wBJZjwPIhPclM1i4bKUtnQqol26caq5L',
// Moneda
'currency' => 'eur',
// On guardar les comandes provisionals
'orders_storage_dir' => __DIR__ . '/data',
// URLs públiques
'success_url' => 'https://kapvoe-portfoli.treblarella.org/checkout/payment-success.php',
'cancel_url' => 'https://kapvoe-portfoli.treblarella.org/checkout/payment-cancel.php',
];
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
require __DIR__ . '/common.php';
kapvoe_require_post();
$config = kapvoe_load_config();
$data = kapvoe_json_input();
$required = [
'product_code',
'product_name',
'price',
'quantity',
'customer_name',
'address',
'postal_code',
'city',
'province',
'phone',
'email',
];
$errors = kapvoe_validate_required($data, $required);
if (!filter_var((string)($data['email'] ?? ''), FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Correu electrònic invàlid';
}
if (!preg_match('/^\d{5}$/', (string)($data['postal_code'] ?? ''))) {
$errors['postal_code'] = 'Codi postal invàlid';
}
if (!preg_match('/^[0-9+\s]{8,20}$/', (string)($data['phone'] ?? ''))) {
$errors['phone'] = 'Telèfon invàlid';
}
$price = (float)str_replace(',', '.', (string)$data['price']);
$quantity = max(1, (int)$data['quantity']);
if ($price <= 0) {
$errors['price'] = 'Preu invàlid';
}
if ($errors) {
kapvoe_json_response(['ok' => false, 'errors' => $errors], 422);
}
$orderId = 'ORD-' . date('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
$unitAmountCents = (int)round($price * 100);
$payload = [
'order_id' => $orderId,
'product_code' => trim((string)$data['product_code']),
'product_name' => trim((string)$data['product_name']),
'unit_amount_cents' => $unitAmountCents,
'quantity' => $quantity,
'customer_name' => trim((string)$data['customer_name']),
'address' => trim((string)$data['address']),
'postal_code' => trim((string)$data['postal_code']),
'city' => trim((string)$data['city']),
'province' => trim((string)$data['province']),
'phone' => trim((string)$data['phone']),
'email' => trim((string)$data['email']),
];
try {
$session = kapvoe_create_checkout_session($config, $payload);
kapvoe_append_order($config, [
'order_id' => $orderId,
'created_at' => date('Y-m-d H:i:s'),
'product_code' => $payload['product_code'],
'product_name' => $payload['product_name'],
'unit_price' => $price,
'quantity' => $quantity,
'customer_name' => $payload['customer_name'],
'address' => $payload['address'],
'postal_code' => $payload['postal_code'],
'city' => $payload['city'],
'province' => $payload['province'],
'phone' => $payload['phone'],
'email' => $payload['email'],
'payment_status' => 'pending',
'stripe_session_id' => $session['id'] ?? '',
'payment_intent_id' => '',
]);
kapvoe_json_response([
'ok' => true,
'checkout_url' => $session['url'] ?? null,
'order_id' => $orderId,
]);
} catch (Throwable $e) {
kapvoe_json_response([
'ok' => false,
'error' => $e->getMessage(),
], 500);
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ca">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pagament cancel·lat</title>
<style>
body{font-family:Arial,Helvetica,sans-serif;background:#081221;color:#fff;margin:0;padding:40px}
.box{max-width:780px;margin:auto;background:#0f1d34;border:1px solid rgba(255,255,255,.12);border-radius:24px;padding:32px}
a{color:#7dd3fc}
</style>
</head>
<body>
<div class="box">
<h1>Pagament cancel·lat</h1>
<p>No s'ha completat el pagament. Pots tornar al catàleg i provar-ho de nou.</p>
<p><a href="https://kapvoe-portfoli.treblarella.org/">Torna al catàleg</a></p>
</div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
$sessionId = htmlspecialchars($_GET['session_id'] ?? '', ENT_QUOTES, 'UTF-8');
?><!doctype html>
<html lang="ca">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pagament correcte</title>
<style>
body{font-family:Arial,Helvetica,sans-serif;background:#081221;color:#fff;margin:0;padding:40px}
.box{max-width:780px;margin:auto;background:#0f1d34;border:1px solid rgba(255,255,255,.12);border-radius:24px;padding:32px}
a{color:#7dd3fc}
</style>
</head>
<body>
<div class="box">
<h1>Pagament correcte</h1>
<p>Hem rebut la teva compra. En breu revisarem la comanda i t'avisarem.</p>
<?php if ($sessionId): ?>
<p><strong>Stripe session:</strong> <?= $sessionId ?></p>
<?php endif; ?>
<p><a href="https://kapvoe-portfoli.treblarella.org/">Torna al catàleg</a></p>
</div>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
$config = kapvoe_load_config();
$payload = file_get_contents('php://input') ?: '';
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = (string)($config['stripe_webhook_secret'] ?? '');
if (!kapvoe_verify_stripe_signature($payload, $sigHeader, $secret)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'error' => 'Signatura Stripe invàlida'
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$event = json_decode($payload, true);
if (!is_array($event)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'error' => 'Payload JSON invàlid'
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$type = (string)($event['type'] ?? '');
$object = $event['data']['object'] ?? null;
if (!is_array($object)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'error' => 'Event sense objecte de dades'
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
try {
switch ($type) {
case 'checkout.session.completed':
$sessionId = (string)($object['id'] ?? '');
$paymentIntentId = (string)($object['payment_intent'] ?? '');
$paymentStatus = (string)($object['payment_status'] ?? '');
if ($sessionId === '') {
throw new RuntimeException('Falta session id');
}
kapvoe_update_order_status(
$config,
$sessionId,
$paymentStatus === 'paid' ? 'paid' : 'pending',
$paymentIntentId
);
$order = kapvoe_get_order_by_session_id($config, $sessionId);
if (!$order) {
throw new RuntimeException('No s\'ha trobat la comanda al CSV');
}
$alreadyUpdated = (string)($order['stock_updated'] ?? '') === '1';
if ($paymentStatus === 'paid' && !$alreadyUpdated) {
kapvoe_mark_order_stock_updated($config, $sessionId);
$alreadyUpdated = true;
}
http_response_code(200);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'handled' => $type,
'session_id' => $sessionId,
'payment_intent_id' => $paymentIntentId,
'already_updated' => $alreadyUpdated
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
default:
http_response_code(200);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'ignored' => $type
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
} catch (Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'error' => $e->getMessage()
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}