Un binario, un'immagine, un volume
Nessun servizio esterno obbligatorio: niente Redis, code o object storage. Email e pagamenti sono opzionali.
Un solo binario Go (arenile) contiene API REST, flusso SSE, job runner, outbox e le due applicazioni React
incorporate con embed.FS (ADR 0001). Il database è SQLite di default, PostgreSQL opzionale (ADR 0002).
browser gestore browser cliente / sito ospite (iframe, embed.js) │ /admin/ /api/admin/v1/* │ /b/{slug} /api/public/v1/{slug}/* ▼ ▼┌─────────────────────────────────────────────────────────────────────────────────────┐│ binario arenile ││ middleware: log, recover, request ID, sessione/chiavi, CSRF, rate limit, ││ idempotenza, header di sicurezza (frame-ancestors per stabilimento) ││ ││ internal/http/admin internal/http/public internal/http/webhooks http/sse ││ (handler sottili sulle interfacce generate da api/openapi.yaml) ││ │ │ │ ▲ ││ ▼ ▼ ▼ │ ││ internal/domain/*: inventory · pricing · policy · booking · checkout · payment · ││ gateway · stripe · cash · fiscal · notify · online · customer · pass · mapping ... ││ │ │ ││ ▼ │ ││ internal/store (Bun, unico posto con SQL) ──► outbox_events ──► internal/jobs ─┘ │└────────────────┼────────────────────────────────────────────────────────────────────┘ ▼ SQLite (WAL, /data/arenile.db) | PostgreSQL (DATABASE_URL)Un binario, un'immagine, un volume
Nessun servizio esterno obbligatorio: niente Redis, code o object storage. Email e pagamenti sono opzionali.
Configurazione come dati
Ogni differenza tra stabilimenti è espressa da risorse, pack, regole e policy, mai da condizioni nel codice.
API-first
Pannello e pagina pubblica usano solo le API documentate; il contratto OpenAPI genera server e client.
Correttezza prima di tutto
Una doppia prenotazione è un difetto bloccante: la disponibilità è garantita dai vincoli del database.
spot_allocations per (spot_id, date, slot). Il vincolo
unique(spot_id, date, slot) rende impossibile la doppia occupazione, anche con 100 richieste parallele,
su SQLite e PostgreSQL. kind ∈ hold, booking, block, season; giorno intero = AM + PM.pool_usage(used, held) aggiornati con
UPDATE … WHERE used + held + :qty <= capacità_effettiva; meno righe aggiornate del previsto ⇒ rollback.disponibili_online = min(liberi, quota − venduti_online) per data e fascia; valgono per i
canali online e api, non per desk.internal/domain/inventory): functional core, imperative shell. plan.go calcola in
modo puro le righe da inserire e le domande ai pool; allocate.go le applica nella transazione del chiamante
con INSERT … ON CONFLICT DO NOTHING e restituisce tutte le mezze giornate in conflitto come ConflictError.pricing.Quote(input, catalogo, regole) → PriceBreakdown non accede a database, orologio o casualità (ADR 0004):
regole, catalogo e data di riferimento arrivano come input. Aritmetica intera in centesimi con arrotondamento
half-even. L’output è salvato come price_snapshot con engine_version: ricalcolare con la stessa versione dà lo
stesso totale. La matrice di listino e il simulatore sono lo stesso calcolo.
Ogni cambio di stato è un metodo di internal/domain/booking, che scrive audit log ed evento outbox nella
stessa transazione della modifica. I canali pubblici usano metodi dedicati (booking/online.go) con un
audit.Actor (cliente o sistema) al posto di un utente autenticato.
Cliente (iframe) API pubblica Dominio / DB Stripe 1 date e fasce ── GET availability ─► inventory (sola lettura, quote online) 2 pack e posto ── POST quote ────────► pricing.Quote (puro) + policy (nessuna scrittura) 3 conferma ── POST bookings ─────► checkout.Create ─ una transazione: booking held + hold sulle risorse + checkout_sessions(hash) ◄── booking_id, checkout_token, hold_expires_at 4 dati, consensi ── PATCH bookings/{id} ► booking.AttachCustomer (versione condizioni = corrente) 5 paga ── POST …/pay ────────► booking.StartPayment (pending_payment, hold ≤ 30 min) gateway.Start: payment_intents + CreatePayment ───────► Checkout Session ◄── redirect (target top) │ 6 POST /webhooks/payments/stripe/{establishment} ◄─────────┘ ParseWebhook (firma) ─ una transazione: webhook_events (dedup) · payments · paid_amount · booking confirmed · hold → booking · auto-assegnazione · audit + outbox booking.confirmed 7 polling ── GET bookings/{id} ─► stato (non conferma mai) notify (sink dell'outbox) → email con link magico, QR, .icsBEGIN modifica (bookings, allocazioni, …) INSERT audit_log INSERT outbox_events (type, payload v1)COMMIT ──► job dispatcher ──► ext.EventSink (estensioni, notify) · webhook in uscita · broker SSEL’evento esiste se e solo se la modifica è confermata; la consegna è almeno una volta. Il bus SSE è avvolto da un
ReplayBus che, alla riconnessione con Last-Event-ID, rimanda gli eventi persi.
internal/app è la composition root: costruisce store, servizi, router e job runner. Ogni consumatore dichiara
la porta che gli serve (ports & adapters) e internal/app/m3.go e m4.go verificano a tempo di
compilazione che le implementazioni le soddisfino. I driver di pagamento stanno in un registry (strategy):
manual e stripe nel core, altri dalle estensioni.
| Risorsa | Budget |
|---|---|
| JavaScript del primo schermo | < 150 KB gzip |
| CSS | < 25 KB |
embed.js |
< 3 KB |
| LCP su 4G | < 2,5 s |
| Larghezza minima | 320 px |
I passi successivi a “Quando” e la mappa sono caricati in modo pigro; npm run budget verifica i limiti in CI.
Per i dettagli: docs/architecture.md nel repository.