146 lines
4.7 KiB
PHP
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']);
|