src/Controller/GuestyBillingWebhookController.php line 18
<?phpnamespace App\Controller;use App\Service\BillingFormLinks;use App\Service\GuestyBillingSignature;use App\Service\InvoicingOperationLock;use App\Service\InvoicingGuestChanges;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\Routing\Annotation\Route;final class GuestyBillingWebhookController extends AbstractController{public function __construct(private BillingFormLinks $links, private InvoicingGuestChanges $guestChanges) {}#[Route('/invoicing/webhooks/guesty/billing-form', name: 'invoicing_guesty_billing_webhook', methods: ['POST'])]public function receive(Request $request): JsonResponse{$directory = dirname(__DIR__, 2).'/var/invoicing';$path = $directory.'/guesty-billing-webhook.json';$config = is_file($path) ? json_decode(file_get_contents($path), true) : [];$body = $request->getContent();$messageId = $request->headers->get('svix-id', $request->headers->get('webhook-id', ''));$time = $request->headers->get('svix-timestamp', $request->headers->get('webhook-timestamp', ''));$signature = $request->headers->get('svix-signature', $request->headers->get('webhook-signature', ''));if (empty($config['enabled']) || strlen($body) > 262144 || !GuestyBillingSignature::valid($body, $messageId, $time, $signature, $config['secret'] ?? '')) {return $this->response(['error' => 'Unauthorized notification.'], 401);}try { $event = json_decode($body, true, 32, JSON_THROW_ON_ERROR); }catch (\JsonException $e) { return $this->response(['error' => 'Invalid notification.'], 400); }if (!is_array($event)) return $this->response(['error' => 'Invalid notification.'], 400);if (($event['event'] ?? '') === 'guest.updated') {$id = $event['guest']['_id'] ?? '';$account = $event['guest']['accountId'] ?? '';if (!is_string($id) || !preg_match('/^[a-f0-9]{24}$/D', $id) || $account !== BillingFormLinks::ACCOUNT) {return $this->response(['error' => 'Invalid guest notification.'], 400);}try {$recorded = $this->guestChanges->record($id, $event['meta']['eventId'] ?? $messageId);return $this->response(['status' => $recorded ? 'queued' : 'duplicate']);} catch (\Throwable $e) {return $this->response(['error' => 'Guest change not confirmed. Notification will be retried.'], 503);}}if (($event['event'] ?? '') !== 'reservation.created.v2') return $this->response(['status' => 'ignored']);$id = $event['data']['_id'] ?? $event['data']['reservationId'] ?? '';$account = $event['data']['accountId'] ?? '';if (!is_string($id) || !preg_match('/^[a-f0-9]{24}$/D', $id) || $account !== BillingFormLinks::ACCOUNT) {return $this->response(['error' => 'Invalid reservation notification.'], 400);}try {return InvoicingOperationLock::run(function () use ($directory, $messageId, $event, $id): JsonResponse {$path = $directory.'/billing-form-events.json';$history = is_file($path) ? json_decode(file_get_contents($path), true) : [];if (!is_array($history)) throw new \RuntimeException('Notification journal unavailable.');$eventId = $event['meta']['eventId'] ?? $messageId;if (!is_string($eventId) || $eventId === '' || strlen($eventId) > 200) throw new \InvalidArgumentException('Invalid event.');$key = hash('sha256', $id.'|'.$eventId);if (isset($history[$key])) return $this->response(['status' => 'duplicate']);$result = $this->links->ensure($id);$history[$key] = ['reservationId' => $id, 'status' => $result['status'], 'completedAt' => date(DATE_ATOM)];$history = array_slice($history, -1000, null, true);$temporary = $path.'.'.bin2hex(random_bytes(8));if (file_put_contents($temporary, json_encode($history, JSON_THROW_ON_ERROR), LOCK_EX) === false) throw new \RuntimeException('Notification journal unavailable.');chmod($temporary, 0600);if (!rename($temporary, $path)) throw new \RuntimeException('Notification journal unavailable.');return $this->response($result);}, false);} catch (\Throwable $e) {// No token, full URL, credentials or guest data in responses/logs. Non-2xx asks Guesty to retry.return $this->response(['error' => 'Billing link not confirmed. Notification will be retried.'], 503);}}private function response(array $body, int $status = 200): JsonResponse{return new JsonResponse($body, $status, ['Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff'] + ($status === 503 ? ['Retry-After' => '60'] : []));}}