Fusionne dans `master` l'état réellement déployé en production. `infra/deploy.sh`
synchronise l'arbre de travail par rsync, pas une branche : `master` est donc
aujourd'hui nettement en retard sur ce qui tourne sur le VPS.

**29 commits · 115 fichiers · +8057 / −2288**

Les 12 premiers commits existaient déjà en local sans avoir été poussés ; cette
PR les publie aussi.

---

## Migration MVC

Les endpoints de `www/api/` sont passés en contrôleurs `src/Controller/`, en deux
phases (booking, unit, rate, quote, puis les 11 restants), plus l'onboarding.
Les tests d'intégration de `tests/Api/` ont servi de filet : ils frappent l'API
en HTTP, donc leur passage atteste que le comportement des endpoints n'a pas
bougé.

## Sécurité

- Erreurs PHP masquées en production, logs applicatifs écrits hors racine web.
- Correction d'un open redirect, d'une injection d'en-têtes mail et d'une fenêtre
  de rejeu sur les webhooks Stripe.
- Verrou `FOR UPDATE` à la création de réservation contre les doubles
  réservations concurrentes.
- Les trois contrôleurs `channel_mapping` passent en `require_super_admin()` :
  la table est une configuration globale, lue sans filtre de propriété par
  `ChannelImportService`, et n'importe quel propriétaire connecté pouvait la
  lister, la modifier, la dupliquer et la supprimer — donc altérer l'import iCal
  de tous les autres locataires. Le menu latéral la classait déjà comme
  super-admin ; seule l'application côté serveur manquait.

## Formulaire de réservation public

Quatre défauts qui le rendaient inopérant ou silencieusement dégradé :

- **Signature HMAC** — le chemin signé était lu depuis `?path=`, paramètre que
  seul le routeur de développement renseigne. nginx et `.htaccess` réécrivent
  vers `index.php` sans le transmettre : tous les appels signés répondaient 403
  en production. Le chemin dérive désormais de `REQUEST_URI`.
- **Emails** — `insertQuery()` renvoyait la string de `lastInsertId()`, rejetée
  par les signatures typées `int` sous `strict_types`. `notifyNewBooking()` et
  `scheduleForBooking()` levaient un `TypeError` attrapé et journalisé à chaque
  réservation : le propriétaire n'était jamais prévenu, aucune séquence n'était
  planifiée.
- **Montants** — `createFromPayload` n'en calculait aucun ; toutes les
  réservations issues du widget restaient à `total_amount NULL`.
- **Double réservation** — la `RuntimeException` du verrou sortait en 500
  « Internal error » au lieu d'un 409 explicite.

Une demande (`pending`) ne bloque plus les dates : le verrou ne compte que les
statuts bloquants, même définition que `isAvailable()`.

## Mode dégradé « sur devis »

Piloté par `pricing.allow_unpriced` (app_config, par propriété, actif par
défaut). Sans tarif sur la période, le devis répond 200 avec `priced: false` et
`totals: null` au lieu d'un 409, et la réservation reste possible — les
disponibilités et les stop-sell continuent de s'appliquer. Le flag à 0 rend
l'ensemble strict : devis **et** réservation refusés.

Emails et back-office n'annoncent plus « 0,00 € » quand le prix est inconnu.

## Widget embarquable

Nouveau dossier `integration/`, où chaque module d'intégration vit séparé du
back-office et n'est qu'un client de l'API publique signée.

`<manahote-booking>` est un composant web autonome, sans dépendance ni bundler.
Chez Wix il se déclare en extension *Site Widget (Custom Element)* — l'extension
iframe est dépréciée côté Wix — et il est rendu directement dans le DOM de la
page publiée : pas de hauteur figée, pas de double défilement sur mobile. Les
mêmes deux lignes de HTML fonctionnent sur WordPress et sur un site statique.

Couleurs et polices suivent le réglage explicite, puis le thème du site
(`--wst-*`), puis un repli neutre. Le secret d'API ne quitte jamais le serveur :
`/api/sign` signe à partir de la seule clé publique.

L'onglet Intégration du back-office est réécrit en conséquence : il conseillait
de stocker le secret dans un backend Velo ou PHP, et documentait un endpoint
`GET /api/quote` qui n'existe pas.

---

## Vérifications

- 246 tests unitaires au vert.
- Chaîne publique des tests API : 21/21. Les autres échecs de cette suite sont
  antérieurs et sans rapport — un `PHPSESSID` périmé dans `phpunit-api.xml` fait
  répondre 302 aux endpoints à session.
- Parcours complet rejoué en HTTP contre l'API, et le widget piloté dans Chrome :
  tarifs réels là où `rate_calendar` est renseigné, « sur devis » ailleurs, jours
  en stop-sell désactivés, recalcul au changement d'option, réservation créée
  avec le bon montant.
- En production : appel signé `GET /api/unit` en 200 (403 avant), widget
  fonctionnel sur un site Wix publié.

## Point d'attention

`infra/nginx/manahote.conf` contient un bloc `location ^~ /public/widget/` déjà
appliqué à la main sur le serveur. `infra/` étant exclu du rsync, ce fichier
n'est qu'une référence pour un futur provisionnement — la fusion ne le déploie
pas.
