src/Controller/GuestyBillingWebhookController.php line 18

  1. <?php
  2. namespace App\Controller;
  3. use App\Service\BillingFormLinks;
  4. use App\Service\GuestyBillingSignature;
  5. use App\Service\InvoicingOperationLock;
  6. use App\Service\InvoicingGuestChanges;
  7. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  8. use Symfony\Component\HttpFoundation\JsonResponse;
  9. use Symfony\Component\HttpFoundation\Request;
  10. use Symfony\Component\Routing\Annotation\Route;
  11. final class GuestyBillingWebhookController extends AbstractController
  12. {
  13.     public function __construct(private BillingFormLinks $links, private InvoicingGuestChanges $guestChanges) {}
  14.     #[Route('/invoicing/webhooks/guesty/billing-form', name: 'invoicing_guesty_billing_webhook', methods: ['POST'])]
  15.     public function receive(Request $request): JsonResponse
  16.     {
  17.         $directory = dirname(__DIR__, 2).'/var/invoicing';
  18.         $path = $directory.'/guesty-billing-webhook.json';
  19.         $config = is_file($path) ? json_decode(file_get_contents($path), true) : [];
  20.         $body = $request->getContent();
  21.         $messageId = $request->headers->get('svix-id', $request->headers->get('webhook-id', ''));
  22.         $time = $request->headers->get('svix-timestamp', $request->headers->get('webhook-timestamp', ''));
  23.         $signature = $request->headers->get('svix-signature', $request->headers->get('webhook-signature', ''));
  24.         if (empty($config['enabled']) || strlen($body) > 262144 || !GuestyBillingSignature::valid($body, $messageId, $time, $signature, $config['secret'] ?? '')) {
  25.             return $this->response(['error' => 'Unauthorized notification.'], 401);
  26.         }
  27.         try { $event = json_decode($body, true, 32, JSON_THROW_ON_ERROR); }
  28.         catch (\JsonException $e) { return $this->response(['error' => 'Invalid notification.'], 400); }
  29.         if (!is_array($event)) return $this->response(['error' => 'Invalid notification.'], 400);
  30.         if (($event['event'] ?? '') === 'guest.updated') {
  31.             $id = $event['guest']['_id'] ?? '';
  32.             $account = $event['guest']['accountId'] ?? '';
  33.             if (!is_string($id) || !preg_match('/^[a-f0-9]{24}$/D', $id) || $account !== BillingFormLinks::ACCOUNT) {
  34.                 return $this->response(['error' => 'Invalid guest notification.'], 400);
  35.             }
  36.             try {
  37.                 $recorded = $this->guestChanges->record($id, $event['meta']['eventId'] ?? $messageId);
  38.                 return $this->response(['status' => $recorded ? 'queued' : 'duplicate']);
  39.             } catch (\Throwable $e) {
  40.                 return $this->response(['error' => 'Guest change not confirmed. Notification will be retried.'], 503);
  41.             }
  42.         }
  43.         if (($event['event'] ?? '') !== 'reservation.created.v2') return $this->response(['status' => 'ignored']);
  44.         $id = $event['data']['_id'] ?? $event['data']['reservationId'] ?? '';
  45.         $account = $event['data']['accountId'] ?? '';
  46.         if (!is_string($id) || !preg_match('/^[a-f0-9]{24}$/D', $id) || $account !== BillingFormLinks::ACCOUNT) {
  47.             return $this->response(['error' => 'Invalid reservation notification.'], 400);
  48.         }
  49.         try {
  50.             return InvoicingOperationLock::run(function () use ($directory, $messageId, $event, $id): JsonResponse {
  51.                 $path = $directory.'/billing-form-events.json';
  52.                 $history = is_file($path) ? json_decode(file_get_contents($path), true) : [];
  53.                 if (!is_array($history)) throw new \RuntimeException('Notification journal unavailable.');
  54.                 $eventId = $event['meta']['eventId'] ?? $messageId;
  55.                 if (!is_string($eventId) || $eventId === '' || strlen($eventId) > 200) throw new \InvalidArgumentException('Invalid event.');
  56.                 $key = hash('sha256', $id.'|'.$eventId);
  57.                 if (isset($history[$key])) return $this->response(['status' => 'duplicate']);
  58.                 $result = $this->links->ensure($id);
  59.                 $history[$key] = ['reservationId' => $id, 'status' => $result['status'], 'completedAt' => date(DATE_ATOM)];
  60.                 $history = array_slice($history, -1000, null, true);
  61.                 $temporary = $path.'.'.bin2hex(random_bytes(8));
  62.                 if (file_put_contents($temporary, json_encode($history, JSON_THROW_ON_ERROR), LOCK_EX) === false) throw new \RuntimeException('Notification journal unavailable.');
  63.                 chmod($temporary, 0600);
  64.                 if (!rename($temporary, $path)) throw new \RuntimeException('Notification journal unavailable.');
  65.                 return $this->response($result);
  66.             }, false);
  67.         } catch (\Throwable $e) {
  68.             // No token, full URL, credentials or guest data in responses/logs. Non-2xx asks Guesty to retry.
  69.             return $this->response(['error' => 'Billing link not confirmed. Notification will be retried.'], 503);
  70.         }
  71.     }
  72.     private function response(array $body, int $status = 200): JsonResponse
  73.     {
  74.         return new JsonResponse($body, $status, ['Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff'] + ($status === 503 ? ['Retry-After' => '60'] : []));
  75.     }
  76. }