- A1: SPEC-MVP-RECESSO.md (criteri + copy IT) - A3: App Proxy /apps/recesso — lookup guest ordine+email (anti-leak), form 2-step, funzione dedicata Conferma recesso, persist WithdrawalRequest + transmittedAt + AuditLog - A4: ricevuta durevole via nodemailer (mailer.server.ts) + receiptSentAt + AuditLog - Design: recesso.server.ts restyle token CSS light/dark, coerente Shopify - Fix: @shopify/shopify-api pinnato 13.1.0 (dedupe) -> tsc clean - PLAN: A5 esteso a motore di stile a 3 livelli Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
327 lines
17 KiB
Markdown
327 lines
17 KiB
Markdown
# SPEC-MVP-RECESSO — Contratto operativo di build
|
||
|
||
> **Ruolo:** riferimento vincolante per gli agenti di build (A2/A3/A4) e di test (A9).
|
||
> Trasforma `ANALISI-REQUISITI-LEGALI.md` + `PLAN.md §2` in criteri testabili + copy IT esatto.
|
||
> **Base legale:** Art. 54-bis Cod. Consumo (D.Lgs 209/2025, recepimento Dir. UE 2023/2673), in
|
||
> vigore per contratti conclusi dal 19/6/2026.
|
||
>
|
||
> **Principi non negoziabili (da ANALISI):**
|
||
> 1. Timestamp = **TRASMISSIONE** (momento in cui il consumatore esercita), **non** ricezione.
|
||
> 2. Il pulsante è **AGGIUNTIVO**, non sostitutivo (restano modulo Allegato I-B + email/PEC).
|
||
> 3. La conferma è una **funzione dedicata a 2 step** ("conferma recesso"), **non** una checkbox.
|
||
> 4. **Vietati dark pattern**: recesso non più oneroso della conclusione del contratto.
|
||
>
|
||
> **Legenda scope:** 🟢 MVP (fasi 2) · 🟡 Fase successiva (A6/A7/A8) · ⚠ da confermare.
|
||
|
||
---
|
||
|
||
## 1. Criteri di accettazione testabili (Given/When/Then)
|
||
|
||
Verificabili da QA (A9). Focus MVP: R1–R7, R11, R12.
|
||
|
||
### R1 — 🟢 Funzione visibile, accessibile, continuativamente disponibile
|
||
- **Dato** un ordine B2C con finestra di recesso ancora aperta,
|
||
**Quando** l'acquirente visita una qualsiasi pagina dove è attivo l'app embed,
|
||
**Allora** vede il pulsante con etichetta esatta **"Recedere dal contratto qui"**.
|
||
- **Dato** un ordine consegnato oggi (giorno 0),
|
||
**Quando** l'acquirente apre la funzione in qualunque momento entro la finestra,
|
||
**Allora** la funzione è raggiungibile e operativa (nessuno stato "disabilitato").
|
||
- **Dato** un ordine con finestra **scaduta**,
|
||
**Quando** l'acquirente tenta il recesso,
|
||
**Allora** il sistema mostra un messaggio di finestra chiusa e NON consente l'invio.
|
||
- **Negativo:** l'etichetta non deve mai essere ambigua ("Gestisci ordine", "Resi") → fail.
|
||
|
||
### R2 — 🟢 Accesso guest (buona prassi, non login forzato)
|
||
- **Dato** un utente NON autenticato,
|
||
**Quando** inserisce numero ordine + email corretti,
|
||
**Allora** completa l'intero flusso senza creare account né effettuare login.
|
||
- **Negativo:** se in qualunque step compare un obbligo di login/registrazione → fail (viola "facilmente accessibile").
|
||
- **Dato** un utente autenticato, **Quando** apre la funzione, **Allora** i campi noti (nome, email, ordine) sono precompilati ma modificabili.
|
||
|
||
### R3 — 🟢 Raccolta dei 4 dati di legge
|
||
- **Dato** il form di recesso,
|
||
**Quando** l'acquirente lo compila,
|
||
**Allora** sono raccolti e persistiti: (a) nome e cognome, (b) identificativo ordine/contratto,
|
||
(c) email (mezzo elettronico per la conferma), (d) testo della dichiarazione di recesso.
|
||
- **Negativo:** invio con uno dei 4 campi obbligatori vuoto → bloccato con messaggio di campo mancante.
|
||
- **Dato** l'invio riuscito, **Quando** si ispeziona il DB, **Allora** esiste un record `WithdrawalRequest` con tutti e 4 i valori.
|
||
|
||
### R4 — 🟢 Conferma a 2 step, funzione dedicata, anti-dark-pattern
|
||
- **Dato** il form compilato,
|
||
**Quando** l'acquirente conferma la compilazione,
|
||
**Allora** viene mostrata una **schermata di riepilogo** con una funzione dedicata etichettata **"Conferma recesso"**.
|
||
- **Dato** la schermata di riepilogo,
|
||
**Quando** l'acquirente NON attiva "Conferma recesso",
|
||
**Allora** nessuna richiesta viene registrata (l'invio è impossibile senza la 2ª azione esplicita).
|
||
- **Negativo (dark pattern):** la conferma NON deve essere una checkbox pre-flaggata, un timer, un
|
||
pop-up dissuasivo o un retention nudge → qualsiasi di questi = fail.
|
||
- **Dato** i due step, **Quando** si contano le azioni, **Allora** sono esattamente 2 (compila → conferma), senza attriti aggiuntivi.
|
||
|
||
### R5 — 🟢 Ricevuta su supporto durevole con timestamp di TRASMISSIONE
|
||
- **Dato** una conferma andata a buon fine (attivazione di "Conferma recesso"),
|
||
**Quando** il backend registra la richiesta,
|
||
**Allora** `transmittedAt` = istante dell'attivazione della conferma (NON istante di ricezione/lettura email).
|
||
- **Dato** la richiesta registrata,
|
||
**Quando** trascorre meno di 1 minuto,
|
||
**Allora** viene inviata al consumatore una ricevuta (email, in MVP via provider stub/log) che contiene:
|
||
**(1)** testo integrale della dichiarazione, **(2)** identificativo ordine, **(3)** data e ora di **trasmissione**, **(4)** nome del consumatore.
|
||
- **Negativo:** ricevuta priva anche di uno solo di questi 4 elementi → fail.
|
||
- **Dato** MVP con provider stub, **Quando** l'email non parte davvero, **Allora** il contenuto della ricevuta è comunque generato e loggato in modo riproducibile (valore probatorio).
|
||
|
||
### R6 — 🟢 Onere probatorio / audit log immutabile
|
||
- **Dato** ogni evento (creazione richiesta, invio ricevuta),
|
||
**Quando** l'evento accade,
|
||
**Allora** è scritto un record `AuditLog` **append-only** con timestamp e hash del payload.
|
||
- **Negativo:** possibilità di UPDATE/DELETE su `AuditLog` dall'applicazione → fail.
|
||
- **Dato** una richiesta, **Quando** la si audita mesi dopo, **Allora** si può dimostrare data/ora e contenuto della dichiarazione dal server (non solo dall'email).
|
||
|
||
### R7 — 🟢 Non più onerosa della conclusione del contratto
|
||
- **Dato** il flusso di recesso completo,
|
||
**Quando** si contano i passaggi utente,
|
||
**Allora** sono **≤** ai passaggi del checkout dello stesso store.
|
||
- **Negativo:** presenza di pop-up di dissuasione, offerte di trattenimento, campi non necessari,
|
||
step aggiuntivi rispetto al checkout → fail.
|
||
|
||
### R11 — 🟢 Pulsante aggiuntivo, non sostitutivo
|
||
- **Dato** l'app installata,
|
||
**Quando** si ispezionano i canali di recesso esistenti (modulo tipo Allegato I-B, email, PEC),
|
||
**Allora** restano tutti attivi e raggiungibili; l'app non li disabilita né li nasconde.
|
||
- **Dato** il flusso app, **Quando** l'acquirente lo usa, **Allora** vede la **nota di coesistenza** (§2) che ricorda gli altri mezzi.
|
||
|
||
### R12 — 🟢 Obblighi informativi Art. 49 (ubicazione della funzione)
|
||
- **Dato** il flusso di recesso,
|
||
**Quando** l'acquirente lo apre,
|
||
**Allora** vede il **testo informativo sul diritto di recesso** (§2) PRIMA dell'invio.
|
||
- **Dato** le informazioni precontrattuali,
|
||
**Quando** il merchant configura l'app,
|
||
**Allora** è disponibile un testo che indica **dove si trova** la funzione nell'interfaccia (per pagine legali/checkout).
|
||
- **Nota:** l'omissione di questa informazione attiva la proroga Art. 53 (12 mesi + 14 gg) → vedi §5.
|
||
|
||
### R9 — 🟡 Item escluso Art. 59 (criterio anticipato, motore in A6)
|
||
- **Dato** un item marcato come escluso (su misura / deperibile / sigillato igiene),
|
||
**Quando** l'acquirente apre il recesso per quell'item,
|
||
**Allora** l'item è segnalato come non recedibile con il **motivo** (messaggio §2), senza recesso "cieco".
|
||
- **MVP:** in fase 2 non è richiesto il motore di esclusione; il copy è pronto per A6.
|
||
|
||
---
|
||
|
||
## 2. Copy deck IT — stringhe ESATTE (hardcodabili)
|
||
|
||
> Tutte le stringhe sono in IT (lingua del contratto per gli store target). i18n = 🟡 Fase A7.
|
||
> Placeholder con `{{doppie graffe}}`. Il testo tra virgolette è letterale.
|
||
|
||
### 2.1 Etichetta pulsante di avvio 🟢
|
||
```
|
||
Recedere dal contratto qui
|
||
```
|
||
Varianti ammesse (equivalenti statutari, se serve adattare la resa grafica — mantenere il verbo "recedere"):
|
||
- `Recedi dal contratto qui`
|
||
- `Recedere dal contratto qui` *(forma preferita, coerente col testo di legge)*
|
||
|
||
### 2.2 Etichetta funzione di conferma (step 2) 🟢
|
||
```
|
||
Conferma recesso
|
||
```
|
||
|
||
### 2.3 Label dei campi del form 🟢
|
||
| Campo | Label esatta | Placeholder / hint |
|
||
|---|---|---|
|
||
| Nome e cognome | `Nome e cognome` | `Mario Rossi` |
|
||
| Identificativo ordine/contratto | `Numero dell'ordine` | `es. #1234` |
|
||
| Email (mezzo elettronico per la conferma) | `Email` | `La tua email dell'ordine` |
|
||
| Testo della dichiarazione di recesso | `Dichiarazione di recesso` | vedi testo precompilato sotto |
|
||
|
||
**Testo precompilato (editabile) della dichiarazione** (buona prassi, deriva dall'Allegato I-B):
|
||
```
|
||
Con la presente comunico il recesso dal contratto di vendita relativo all'ordine {{orderName}}.
|
||
```
|
||
|
||
### 2.4 Testo informativo sul diritto di recesso (Art. 49) — mostrato prima dell'invio 🟢
|
||
```
|
||
Hai diritto di recedere da questo contratto entro 14 giorni senza dover fornire alcuna motivazione.
|
||
Il termine decorre dalla consegna del bene (o dalla conclusione del contratto per i servizi).
|
||
Compilando e confermando questo modulo eserciti il recesso: riceverai via email una ricevuta con
|
||
la data e l'ora di trasmissione della tua dichiarazione.
|
||
```
|
||
|
||
### 2.5 Template email — ricevuta su supporto durevole 🟢
|
||
**Oggetto:**
|
||
```
|
||
Ricevuta della tua richiesta di recesso — Ordine {{orderName}}
|
||
```
|
||
**Corpo:**
|
||
```
|
||
Gentile {{customerName}},
|
||
|
||
confermiamo di aver ricevuto la Sua dichiarazione di recesso relativa all'ordine {{orderName}},
|
||
trasmessa tramite la funzione di recesso presente sul nostro sito.
|
||
|
||
Dettagli della richiesta:
|
||
- Data e ora di trasmissione: {{transmittedAt}}
|
||
- Ordine: {{orderName}}
|
||
- Nome del consumatore: {{customerName}}
|
||
|
||
Testo integrale della dichiarazione di recesso trasmessa:
|
||
"{{statementText}}"
|
||
|
||
Questa comunicazione costituisce la ricevuta su supporto durevole della Sua dichiarazione di
|
||
recesso, ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora sopra indicate
|
||
attestano il momento della trasmissione della dichiarazione.
|
||
|
||
Le invieremo separatamente le istruzioni per l'eventuale restituzione dei beni e i tempi di rimborso.
|
||
|
||
Restano comunque validi anche gli altri mezzi per esercitare il recesso (modulo tipo di cui
|
||
all'Allegato I, parte B, o qualsiasi altra dichiarazione esplicita, anche via email): questa
|
||
funzione è aggiuntiva e non sostituisce tali strumenti.
|
||
|
||
{{shopName}}
|
||
```
|
||
> **Vincolo build:** `{{transmittedAt}}` va reso in formato leggibile e conservabile con fuso
|
||
> orario esplicito, es. `06/07/2026, 14:32:07 (Europe/Rome, CEST)`. È lo stesso istante di `transmittedAt` del DB.
|
||
|
||
### 2.6 Messaggi di successo / conferma finale 🟢
|
||
**Schermata finale (dopo "Conferma recesso"):**
|
||
```
|
||
Recesso trasmesso correttamente.
|
||
Abbiamo registrato la tua dichiarazione di recesso per l'ordine {{orderName}} in data {{transmittedAt}}.
|
||
Ti abbiamo inviato una ricevuta all'indirizzo {{email}}.
|
||
```
|
||
**Sotto-testo (coesistenza):** vedi §2.9.
|
||
|
||
### 2.7 Messaggi di errore 🟢
|
||
| Caso | Messaggio (UI) |
|
||
|---|---|
|
||
| Ordine non trovato **o** email non corrispondente (stesso messaggio, no leak) | `Non abbiamo trovato un ordine con questi dati. Verifica il numero dell'ordine e l'email usata per l'acquisto.` |
|
||
| Campo obbligatorio mancante | `Compila tutti i campi obbligatori per continuare.` |
|
||
| Email in formato non valido | `Inserisci un indirizzo email valido.` |
|
||
| Finestra di recesso scaduta | `Il termine di 14 giorni per il recesso su questo ordine è terminato. Puoi comunque contattarci per altre richieste.` |
|
||
| Errore generico invio | `Si è verificato un problema. Riprova tra qualche istante; se persiste, contattaci.` |
|
||
|
||
> **Regola anti-enumeration (R2/§6):** "ordine inesistente" ed "email non combaciante" DEVONO
|
||
> mostrare lo **stesso identico messaggio**. Nessun dettaglio che riveli l'esistenza dell'ordine.
|
||
|
||
### 2.8 Messaggio prodotto escluso (Art. 59) 🟡 (copy MVP-ready, logica in A6)
|
||
```
|
||
Per questo prodotto il diritto di recesso non è previsto ({{motivoEsclusione}}, ai sensi
|
||
dell'art. 59 del Codice del Consumo). Per informazioni o altre richieste, contattaci.
|
||
```
|
||
Valori di `{{motivoEsclusione}}`:
|
||
- `prodotto realizzato su misura o personalizzato`
|
||
- `prodotto deperibile o a rapida scadenza`
|
||
- `prodotto sigillato, aperto dopo la consegna, non restituibile per motivi igienici o di salute`
|
||
|
||
### 2.9 Nota di coesistenza (pulsante aggiuntivo) 🟢
|
||
```
|
||
Questa funzione è un modo aggiuntivo per esercitare il recesso. Puoi comunque usare il modulo
|
||
tipo (Allegato I, parte B) o inviare qualsiasi dichiarazione esplicita, anche via email.
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Campi raccolti (UI) → modello dati Prisma `WithdrawalRequest`
|
||
|
||
> Modello sorgente: `PLAN.md §3`. I nomi Prisma nel PLAN sono in italiano; qui affiancati agli
|
||
> alias EN citati nel brief. **⚠ Scelta da confermare:** standardizzare i nomi campo (IT vs EN).
|
||
|
||
| Campo UI | Campo Prisma (PLAN §3) | Alias EN (brief) | Obbligatorio | Note |
|
||
|---|---|---|---|---|
|
||
| Nome e cognome | `nomeCliente` | `customerName` | **Sì** | Art. 54-bis lett. (a) |
|
||
| Numero dell'ordine | `orderName` (+ `orderId` interno) | `orderName` / `orderId` | **Sì** | `orderName` = mostrato (#1234); `orderId` = GID Shopify risolto dal lookup |
|
||
| Email | `email` | `email` | **Sì** | Mezzo elettronico per la conferma, lett. (c) |
|
||
| Dichiarazione di recesso | `testoDichiarazione` | `statementText` | **Sì** | Testo integrale trasmesso, va nella ricevuta |
|
||
| — (generato al submit) | `trasmessoAt` | `transmittedAt` | **Sì (auto)** | Istante di TRASMISSIONE = click "Conferma recesso" |
|
||
| — (dal contesto) | `canale` | `channel` | **Sì** | `guest` \| `account` |
|
||
| — (dal contesto) | `locale` | `locale` | **Sì** | MVP fisso `it`; multi-lingua = 🟡 A7 |
|
||
| — (derivato) | `tipoProdotto` | — | 🟡 A6 | beni/servizi/digitale, per calcolo scadenza |
|
||
| — (sistema) | `stato` | — | Sì (default) | `trasmessa` all'invio |
|
||
| — (sistema) | `ricevutaInviataAt` | — | No | valorizzato dopo invio ricevuta |
|
||
| — (derivato) | `scadenzaCalcolata` | — | 🟡 A6 | dal motore compliance (§5) |
|
||
|
||
**Obbligatori MVP (blocco invio se mancanti):** `nomeCliente`, `orderName`, `email`, `testoDichiarazione`.
|
||
**Auto MVP:** `trasmessoAt`, `canale`, `locale`, `stato`.
|
||
|
||
---
|
||
|
||
## 4. Flusso a 2 step (anti-dark-pattern) 🟢
|
||
|
||
```
|
||
STEP 0 — Avvio
|
||
Pulsante "Recedere dal contratto qui" (Theme App Extension) → apre /apps/recesso (App Proxy).
|
||
|
||
STEP 1 — Lookup guest
|
||
Input: Numero ordine + Email. Nessun login richiesto.
|
||
→ match OK: prosegue. → miss: messaggio unico anti-leak (§2.7), nessun dettaglio.
|
||
|
||
STEP 2 — Form dati + dichiarazione
|
||
Campi: Nome e cognome, Email (precompilata), Dichiarazione (precompilata, editabile).
|
||
Mostrato SOPRA il form: testo informativo Art. 49 (§2.4).
|
||
Azione: "Continua" → schermata di riepilogo.
|
||
|
||
STEP 3 — Riepilogo + conferma dedicata
|
||
Mostra: ordine, nome, email, testo dichiarazione.
|
||
Funzione dedicata: "Conferma recesso" (§2.2). ← unica azione che registra la richiesta.
|
||
Al click: transmittedAt = ora, persistenza record, AuditLog, invio ricevuta.
|
||
|
||
STEP 4 — Successo
|
||
Messaggio §2.6 + nota coesistenza §2.9.
|
||
```
|
||
|
||
**Vincoli vincolanti (fail se violati):**
|
||
- Numero azioni utente ≤ passaggi del checkout dello store (R7).
|
||
- Nessun pop-up dissuasivo, offerta di trattenimento, timer, o retention nudge.
|
||
- Nessuna checkbox pre-flaggata; la conferma è **funzione/pulsante dedicato**, non un flag.
|
||
- Nessun login o registrazione forzati in nessuno step.
|
||
- Gli altri canali di recesso restano visibili/attivi (R11).
|
||
|
||
---
|
||
|
||
## 5. Regole di scadenza (compliance engine) 🟡 (spec per A6)
|
||
|
||
> Fonte: ANALISI §2 punto 6, §2 punto 8, §3. Tipi: **beni**, **servizi**, **contenuto digitale**.
|
||
> Timezone di riferimento: `Europe/Rome`. La finestra è di **14 giorni**.
|
||
|
||
**Formule (giorno 0 = data evento di decorrenza):**
|
||
- **Beni:** `scadenza = dataConsegna + 14 giorni`.
|
||
- Beni in **più lotti/pezzi** (un solo ordine): decorre dalla **consegna dell'ultimo** bene → `dataConsegnaUltimoLotto + 14 giorni`.
|
||
- **Servizi:** `scadenza = dataConclusioneContratto + 14 giorni`.
|
||
- **Contenuto digitale:** decorrenza propria (a parte); gestione dedicata, **non** assimilare ai beni.
|
||
- **Estensione per omessa informazione (Art. 53):** se il merchant NON ha fornito l'informazione
|
||
sul diritto/ubicazione della funzione (R12/Art. 49):
|
||
`scadenza = decorrenzaBase + 12 mesi + 14 giorni`.
|
||
Se l'informazione viene fornita entro i 12 mesi: la finestra dei 14 giorni riparte dalla data in
|
||
cui l'informazione è fornita.
|
||
|
||
**Note per A6:**
|
||
- Fonte di verità unica nel motore compliance; input consegna dai webhook fulfillment.
|
||
- Fulfillment parziale → tracciare per-item; usare la consegna più recente pertinente all'ordine.
|
||
- Alla scadenza, il flusso mostra il messaggio "finestra chiusa" (§2.7) e blocca l'invio.
|
||
|
||
---
|
||
|
||
## 6. Lookup guest & prevenzione abuso 🟢
|
||
|
||
**Regole di match:**
|
||
- Chiave di ricerca: **Numero ordine (`orderName`) + Email**, entrambi devono combaciare.
|
||
- **Nessun leak:** ordine inesistente ed email non corrispondente producono lo **stesso** messaggio
|
||
(§2.7). Nessuna differenza in testo, codice di stato o tempo di risposta osservabile.
|
||
|
||
**Anti-abuso (superficie di enumeration ordini) — nota per A9/A3:**
|
||
- **Rate-limit** sul lookup: per IP e/o per email (es. N tentativi / finestra temporale) → 🟢 predisporre in A3, verifica avversariale in A9.
|
||
- Considerare **backoff/captcha** dopo soglia di tentativi falliti.
|
||
- Log dei tentativi falliti in `AuditLog` (senza PII sensibile in chiaro non necessaria).
|
||
- Risposte a **tempo costante** per non rivelare l'esistenza dell'ordine via timing.
|
||
|
||
---
|
||
|
||
## Confini MVP (esplicito)
|
||
|
||
| In MVP 🟢 | Fase successiva 🟡 |
|
||
|---|---|
|
||
| Pulsante + flusso guest 2 step | Motore esclusioni Art. 59 (A6) |
|
||
| Raccolta 4 dati + persistenza | Calcolo scadenza per tipo prodotto (A6) |
|
||
| Conferma dedicata anti-dark-pattern | Logica rimborso / diminuzione valore (A6) |
|
||
| Ricevuta durevole con timestamp trasmissione (provider **stub**) | Provider email reale (pre-go-live) |
|
||
| AuditLog append-only | i18n multi-lingua EN/DE/FR/ES (A7) |
|
||
| Copy IT hardcoded | Webhook GDPR (A8), Billing (A10) |
|
||
| Rate-limit base + anti-leak | Rate-limit avanzato / captcha (A9) |
|