Files
Critters/receive-purchase.php
T
pauljd 35d187b4c2
pauljd/davidson-ci: Davidson CI / quality (push) Successful in 13s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 1m0s
further Davidson CI testing
2026-09-09 22:55:59 +01:00

146 lines
4.7 KiB
PHP

<?php
require __DIR__ . '/db.php';
require __DIR__ . '/lib/ticket.php';
header('Content-Type: application/json');
function respond(int $statusCode, array $body = []): void
{
http_response_code($statusCode);
echo json_encode($body);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
respond(405, ['error' => 'Method not allowed']);
}
$rawBody = file_get_contents('php://input');
if ($rawBody === false || $rawBody === '') {
respond(400, ['error' => 'Empty request body']);
}
$signatureHeader = $_SERVER['HTTP_PADDLE_SIGNATURE'] ?? '';
if ($signatureHeader === '') {
respond(400, ['error' => 'Missing Paddle-Signature header']);
}
$signatureParts = [];
foreach (explode(';', $signatureHeader) as $part) {
$pair = explode('=', $part, 2);
if (count($pair) === 2) {
$signatureParts[trim($pair[0])] = trim($pair[1]);
}
}
$timestamp = $signatureParts['ts'] ?? '';
$providedHash = $signatureParts['h1'] ?? '';
if ($timestamp === '' || $providedHash === '' || !ctype_digit($timestamp)) {
respond(400, ['error' => 'Malformed Paddle-Signature header']);
}
$webhookSecret = getenv('HOOK_SECRET');
if ($webhookSecret === false || $webhookSecret === '') {
error_log('receive-purchase.php: HOOK_SECRET is not configured');
respond(500, ['error' => 'Server misconfiguration']);
}
$signedPayload = $timestamp . ':' . $rawBody;
$expectedHash = hash_hmac('sha256', $signedPayload, $webhookSecret);
if (!hash_equals($expectedHash, $providedHash)) {
respond(401, ['error' => 'Invalid signature']);
}
$maxTimestampAgeSeconds = 300;
if (abs(time() - (int) $timestamp) > $maxTimestampAgeSeconds) {
respond(401, ['error' => 'Signature timestamp outside allowed window']);
}
$event = json_decode($rawBody, true);
if (!is_array($event)) {
respond(400, ['error' => 'Invalid JSON payload']);
}
$eventId = $event['event_id'] ?? null;
if (!is_string($eventId) || $eventId === '') {
respond(400, ['error' => 'Missing event_id']);
}
$pdo = getDbConnection();
$pdo->exec('CREATE TABLE IF NOT EXISTS processed_webhook_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
$pdo->exec('CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
credits_added INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_transactions_user_id ON transactions (user_id)');
$creditsPerPrice = [
'pri_01m1v28mknf5pjm5c99cvxa5k4' => 20,
'pri_01m1v2aegbrdvwhw1jf7b0s6va' => 50,
];
$pdo->beginTransaction();
try {
// Claim the event ID first, inside the transaction: SQLite's unique constraint
// makes this the atomic compare-and-set that decides whether this delivery is
// the one that gets to apply credits. A losing concurrent delivery fails here
// before touching the user's balance.
$claimStmt = $pdo->prepare('INSERT INTO processed_webhook_events (event_id) VALUES (:event_id)');
$claimStmt->execute(['event_id' => $eventId]);
} catch (PDOException $e) {
$pdo->rollBack();
if (str_contains($e->getMessage(), 'UNIQUE constraint failed')) {
respond(200, ['status' => 'already_processed']);
}
throw $e;
}
if (($event['event_type'] ?? '') === 'transaction.completed') {
$data = $event['data'] ?? [];
$ticket = $data['custom_data']['user_id'] ?? null;
$userId = is_string($ticket) ? decodeTicket($ticket, 'purchase') : null;
$items = is_array($data['items'] ?? null) ? $data['items'] : [];
$creditsToAdd = 0;
foreach ($items as $item) {
$priceId = $item['price']['id'] ?? null;
$quantity = $item['quantity'] ?? 1;
if (!is_int($quantity) || $quantity < 1 || $quantity > 100) {
continue;
}
if (is_string($priceId) && isset($creditsPerPrice[$priceId])) {
$creditsToAdd += $creditsPerPrice[$priceId] * $quantity;
}
}
if ($creditsToAdd > 0 && $userId !== null) {
$userExistsStmt = $pdo->prepare('SELECT 1 FROM users WHERE id = :id');
$userExistsStmt->execute(['id' => $userId]);
if ($userExistsStmt->fetch()) {
$creditStmt = $pdo->prepare('UPDATE users SET credits = credits + :credits WHERE id = :id');
$creditStmt->execute(['credits' => $creditsToAdd, 'id' => $userId]);
$transactionStmt = $pdo->prepare('INSERT INTO transactions (user_id, credits_added) VALUES (:user_id, :credits_added)');
$transactionStmt->execute(['user_id' => $userId, 'credits_added' => $creditsToAdd]);
}
}
}
$pdo->commit();
respond(200, ['status' => 'ok']);