Compare commits

...

4 Commits

Author SHA1 Message Date
a6a1900287 R1 A6-bis: operativita' per stato ordine (toggle) + checklist compliance
Comportamenti Pizeta-confirmed, tutti toggle per-shop:
- stateAwareEmail (default ON): la ricevuta durevole include un blocco operativo
  per stato - non evaso => annullo+rimborso; spedito/consegnato => istruzioni reso
  (indirizzo, spese a carico Art.57, prodotto integro, rimborso dopo rientro).
- autoCancelUnfulfilled (default OFF): recesso su ordine non evaso => orderCancel
  (refund+restock). Abilita anche lo stop del remarketing via orders/cancelled.
- returnAtCustomerExpense / returnInstructions / returnAddress: config istruzioni reso.

Impl: helper orderState + cancelOrder (recesso.server); renderOperationalBlock +
param operational in renderReceiptHtml (emailTemplate); mailer passa operational;
proxy calcola stato, passa alla ricevuta, auto-annulla; sezione admin 'Reso e stato
ordine'. Migrazione a6bis_state_ops.

+ CHECKLIST-COMPLIANCE-MERCHANT.md: cosa fa l'app vs obblighi del merchant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:16:22 +02:00
945f1a6230 PLAN: integra A6-bis (operativita' per stato ordine, Pizeta-confirmed) + piano consolidato stato/residuo
- A6-bis: toggle per-shop stateAwareEmail / autoCancelUnfulfilled /
  returnAtCustomerExpense / returnInstructions / returnAddress. Fonte: call
  Pizeta 2026-06-16 + AUDIT-STATI-ORDINE.md.
- Sezione 4-ter: vista unica FATTO vs RESIDUO (R1..R7) con ordine consigliato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:05:35 +02:00
883bd82708 Avviso storefront per ordini gia' annullati/rimborsati (info non bloccante)
Al passo 2 del recesso, se l'ordine risulta gia' annullato (cancelledAt) o
rimborsato/voided (financialStatus), mostra un banner informativo ambra (non
blocca): il recesso resta ammesso (diritto incondizionato + funzione sempre
accessibile Art. 54-bis), ma il consumatore e' informato dello stato.

- recesso.copy: NOTICE.orderClosed.
- recesso.server: noticeBanner + campo notice in renderStep2 + stile .notice ambra con icona.
- proxy: calcola orderClosed al lookup e passa la notice a renderStep2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 15:46:51 +02:00
3968d34011 Audit stati ordine + fix P1 (finestra su consegna, ordini chiusi)
Audit AUDIT-STATI-ORDINE.md: matrice stato ordine x normativa (Art. 52/56/57) x
comportamento attuale x gap, con fix prioritizzati.

Fix P1 (correttezza legale):
- G1+G4: la finestra 14gg decorre dalla CONSEGNA (evento fulfillment DELIVERED),
  non dalla spedizione (Art. 52 = possesso fisico). Se non consegnato, la finestra
  non e' iniziata -> computeDeadline null -> non blocca mai.
- G5: ordini annullati (cancelledAt) o rimborsati/voided (displayFinancialStatus)
  -> skip creazione reso (evita doppio reso/rimborso), audit shopify_return_skipped.
- lookupOrder esteso: deliveredAt (da eventi fulfillment), cancelledAt, financialStatus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 15:06:38 +02:00
11 changed files with 551 additions and 45 deletions

118
AUDIT-STATI-ORDINE.md Normal file
View File

@@ -0,0 +1,118 @@
# Audit - Recesso per stato dell'ordine vs normativa
Verifica dello strumento (app + Shopify) rispetto al Codice del Consumo, per stato
dell'ordine (in elaborazione / spedito / consegnato / annullato). Obiettivo:
mappare, per ogni stato, il trattamento legale corretto, cosa fa oggi lo strumento,
e i gap con i fix prioritizzati.
Fonti normative (verificate 2026-07-07):
- Art. 52 Cod. Consumo - decorrenza termine 14 gg.
- Art. 56 - obblighi del professionista (rimborso).
- Art. 57 - obblighi del consumatore (restituzione, spese).
- Art. 54-bis - funzione/pulsante di recesso (D.Lgs 209/2025, in vigore 19/06/2026).
---
## 1. Principi legali (verificati)
1. **Il diritto nasce dalla conclusione del contratto.** Il consumatore puo' recedere
anche PRIMA della spedizione/consegna. Non serve aver ricevuto il bene per recedere.
2. **Decorrenza del termine di 14 giorni (Art. 52):**
- Beni: dal giorno in cui il consumatore (o terzo da lui designato, diverso dal
vettore) acquisisce il **possesso fisico** del bene = **CONSEGNA**. Non la spedizione.
- Beni multipli in un solo ordine, consegnati separatamente: dal possesso
dell'**ultimo** bene.
- Servizi: dalla **conclusione del contratto**.
- Se il professionista non fornisce l'informativa sul recesso: termine esteso fino
a 12 mesi + 14 gg. (L'app FORNISCE l'informativa -> vale il termine ordinario.)
3. **Rimborso (Art. 56):** entro **14 gg** da quando il professionista e' informato del
recesso. Comprende le spese di consegna standard. Il professionista puo' **trattenere**
il rimborso finche' non ha ricevuto i beni o finche' il consumatore non prova di
averli rispediti (salvo offerta di ritiro). Stesso mezzo di pagamento.
4. **Restituzione e spese (Art. 57):** il consumatore restituisce entro 14 gg dalla
comunicazione. Sostiene il **costo diretto della restituzione SOLO SE** il
professionista lo ha informato di tale onere; altrimenti lo sostiene il professionista.
Il consumatore risponde solo della diminuzione di valore da manipolazione oltre il
necessario; NON risponde se non e' stato informato del diritto di recesso.
---
## 2. Matrice: stato ordine x trattamento x comportamento attuale x gap
| Stato Shopify | Recesso ammesso? | Finestra 14gg | Gestione corretta | Cosa fa OGGI l'app | Gap |
|---|---|---|---|---|---|
| **Non evaso** (in elaborazione, pagato) | Si' | NON iniziata (nessun possesso) | Annullamento + rimborso pieno (incl. consegna). Nessun reso | Reso: skip (no_returnable). Tag + notifica. Finestra: calcola da data ordine | **G1** finestra puo' bloccare a torto; **G2** nessun annullo/rimborso |
| **Spedito / in transito** (evaso, non consegnato) | Si' | NON iniziata o in decorrenza solo alla consegna | Rifiuto consegna o reso dopo ricezione | Reso creato (perche' "fulfilled"). Tag + notifica. Finestra: da data spedizione | **G3** reso forse prematuro; **G4** finestra da spedizione non da consegna |
| **Consegnato** | Si' (entro finestra) | Decorre dalla **consegna** | Reso + rimborso | Reso creato. Tag + notifica. Finestra: da data spedizione | **G4** finestra ancorata a spedizione non a consegna |
| **Parz. evaso** | Si' | Dall'ultimo bene consegnato | Reso parziale | Reso solo per righe evase. Finestra: da ultima evasione | **G4** + nuance parziale/esclusioni |
| **Annullato / rimborsato** | Gia' chiuso | N/A | Nessuna azione (gia' risolto) | Processa comunque: crea record; reso puo' fallire | **G5** nessun rilevamento stato chiuso -> rischio doppio reso/rimborso |
---
## 3. Gap dettagliati e fix
### G1 - Finestra blocca a torto ordini non consegnati [P1]
`computeDeadline` usa `fulfilledAt || createdAt`. Su ordine NON evaso usa la data
ordine: se enforceWindow attivo e l'ordine ha piu' di 14 gg ma non e' mai stato
consegnato, il recesso viene bloccato -> **errato** (la finestra non e' nemmeno iniziata).
**Fix:** se non c'e' consegna, la finestra NON e' iniziata -> non bloccare mai.
### G4 - Finestra ancorata a spedizione, non a consegna [P1]
Art. 52 = possesso fisico (consegna). Oggi il riferimento e' `fulfillment.createdAt`
(~ spedizione), che precede la consegna -> scadenza calcolata troppo presto ->
rischio di bloccare recessi ancora validi.
**Fix:** usare la data dell'evento di **consegna** (`displayFulfillmentStatus = DELIVERED`
+ data evento di consegna dei fulfillment). Se spedito ma non consegnato -> finestra
non iniziata. Fallback conservativo se il dato consegna manca: non bloccare.
### G5 - Nessun rilevamento di ordini annullati/rimborsati [P1]
Su ordine gia' annullato/rimborsato l'app processa comunque (crea record, tenta reso).
**Fix:** leggere `cancelledAt` / `displayFinancialStatus` (REFUNDED/VOIDED) e
`returns` esistenti; se gia' chiuso -> registrare il recesso ma saltare reso e avvisare
il merchant (niente doppio rimborso).
### G2 - Pre-spedizione: nessun annullo/rimborso automatico [P2]
Legalmente pre-consegna e' un annullamento. Oggi: solo tag + notifica, merchant manuale.
**Fix (opzionale):** opzione Settings "annulla/rimborsa in automatico gli ordini non
evasi al recesso" (orderCancel/refundCreate). Richiede scope aggiuntivi -> valutare.
### G3 - Reso creato alla spedizione, non alla consegna [P2]
Creiamo il reso appena l'ordine e' "fulfilled" (spedito), anche se non consegnato.
Il flusso reso Shopify assume beni presso il consumatore.
**Fix:** valutare se creare il reso solo a consegna avvenuta (DELIVERED), altrimenti
solo tag + notifica finche' non consegnato.
### G6 - Informativa spese di restituzione (Art. 57) [P2]
Il consumatore paga il reso SOLO se informato. Verificare che storefront/ricevuta
lo dichiarino; altrimenti l'onere e' del merchant.
**Fix:** riga informativa "le spese di restituzione sono a tuo carico" (configurabile:
chi paga) nel modal e/o nella ricevuta. Coordinare con testo Art. 49 gia' presente.
### G7 - Rimborso: tempi e trattenuta (Art. 56) [P3]
L'app non gestisce rimborsi (giusto lasciarli a Shopify/merchant). Ma il merchant va
aiutato sui tempi (14 gg) e sulla facolta' di trattenere fino a riconsegna.
**Fix:** nella notifica al merchant, ricordare "rimborso entro 14 gg; puoi trattenere
fino a riconsegna dei beni o prova di spedizione". (Solo copy, no logica.)
---
## 4. Priorita' consigliata
- **P1 (correttezza legale) - FATTO (2026-07-07):** G1 + G4 (finestra su data di
CONSEGNA via evento `DELIVERED`; mai bloccare se non consegnato) + G5 (ordini
annullati/rimborsati/voided -> skip reso). `lookupOrder` esteso con data consegna,
`cancelledAt`, `displayFinancialStatus`.
- **P2:** G6 (informativa spese reso) + G3 (reso a consegna) + G2 (annullo/rimborso
pre-spedizione opzionale).
- **P3:** G7 (copy rimborso nella notifica merchant).
---
## 5. Note
- Tutto A6 (finestra/esclusioni) e' oggi dietro toggle default OFF: i gap G1/G4 non
sono attivi finche' il merchant non abilita l'enforcement. Comunque da correggere
prima di consigliarne l'attivazione.
- Servizi (non beni): decorrenza dalla conclusione. L'app oggi ragiona su beni/ordini
fisici; per merchant di soli servizi la finestra andrebbe ancorata a `createdAt`
(gia' fallback) - ok, ma da esplicitare se rilevante.

View File

@@ -0,0 +1,43 @@
# Recesso - Cosa fa l'app vs cosa deve fare il merchant
Ripartizione delle responsabilita' di conformita' (Art. 54-bis + Codice del
Consumo). L'app copre la FUNZIONE elettronica di recesso; alcuni obblighi restano
in capo al merchant. Da consegnare col progetto.
---
## 1. Cosa GARANTISCE l'app (automatico, non disattivabile)
- **Pulsante/funzione di recesso sempre accessibile** (footer o dove scelto), **guest**, senza login.
- Raccolta della **dichiarazione inequivocabile** (nome, n. ordine, email, testo) con **conferma dedicata a 2 step** (nessun dark pattern).
- Registrazione con **timestamp di TRASMISSIONE** (onere della prova, Art. 54-bis).
- **Ricevuta su supporto durevole** al consumatore: dichiarazione + timestamp + avviso di legge - **sempre inviata, contenuto legale non modificabile** dal merchant.
- **Audit log immutabile** delle richieste ed eventi.
- Coesistenza col reso/rimborso **nativo Shopify** (crea il Reso per gli ordini evasi).
## 2. Cosa il merchant CONFIGURA nell'app (Impostazioni, opzionale)
- Testi email (oggetto/introduzione/nota) - le parti legali restano fisse.
- Notifica al merchant (on/off + indirizzo email).
- Tag "Recesso" sull'ordine (on/off).
- Finestra 14 gg: enforcement on/off + giorni (default 14, calcolata sulla **consegna**).
- Esclusioni Art. 59 (prodotti/tag non recedibili) + enforcement on/off.
- **[R1 A6-bis]** Ricevuta differenziata per stato ordine; annullo automatico ordini non evasi; indirizzo reso + spese a carico cliente + istruzioni di reso.
## 3. Cosa il merchant deve fare FUORI dall'app (obblighi propri)
- **Informativa precontrattuale** sul diritto di recesso (Art. 49) nelle pagine/checkout.
- Mettere a disposizione il **modulo tipo** di recesso (Allegato I, parte B) - coesiste col pulsante.
- **Emettere il rimborso entro 14 gg** dalla notifica (Art. 56) se non usa l'annullo/rimborso automatico dell'app. Puo' trattenere fino a riconsegna merce o prova di spedizione.
- Gestire **resi parziali** e rimborsi proporzionali (pannello ordini Shopify).
- Definire la **politica di reso** (indirizzo, spese, integrita' prodotto) coerente con quanto mostra l'app.
- **Verifica manuale della data di consegna** se il corriere non trasmette l'evento a Shopify (in tal caso la finestra automatica non blocca).
- Privacy policy, condizioni di vendita, gestione dati (GDPR) del negozio.
- Configurare le **esclusioni Art. 59** solo per prodotti realmente esclusi (mala-config = negare il diritto a torto).
## 4. Note
- L'app e' uno **strumento** di conformita', non sostituisce la consulenza legale.
- Prima del go-live (e soprattutto per la versione pubblica/App Store) consigliata una **review legale**, anche per chiudere i punti ⚠ dottrinali in `ANALISI-REQUISITI-LEGALI.md`.
- Ambito: **beni B2C online**. Servizi e beni digitali hanno decorrenza/esclusioni diverse (Art. 52/59) - da valutare per merchant fuori scope.
- Approfondimento stati ordine: `AUDIT-STATI-ORDINE.md`.

41
PLAN.md
View File

@@ -140,7 +140,7 @@ Corretta e integrata dopo `ANALISI-REQUISITI-LEGALI.md`.
## 3. Modello dati (bozza Prisma)
- **Shop** — dominio, accessToken(cifrato), piano, installedAt
- **Settings** — labelPulsante, brandColors, indirizzoReso, giorniFinestraDefault, overrideMercati, testoInfoRecesso
- **Settings** — labelPulsante, brandColors, indirizzoReso, giorniFinestraDefault, overrideMercati, testoInfoRecesso, email (emailSubject/emailIntro/emailNote), + toggle per-shop: notifyEnabled/notifyEmail, tagEnabled, enforceWindow, enforceExclusions, **stateAwareEmail, autoCancelUnfulfilled, returnAtCustomerExpense, returnInstructions**
- **ExclusionRule** — scope(prodotto|collezione|tag|tutto), targetId, motivo(sumisura|deperibile|igiene), attiva
- **WithdrawalRequest** — shopId, orderId, orderName, nomeCliente, email, testoDichiarazione, **trasmessoAt (ts)**, locale, canale(guest|account), tipoProdotto, stato, ricevutaInviataAt, scadenzaCalcolata
- **AuditLog** — shopId, evento, hashPayload, ts (append-only, immutabile)
@@ -173,6 +173,14 @@ input / deliverable / criteri di uscita espliciti. **Nessun avvio automatico**
3. **CSS custom** — escape hatch per personalizzazione totale.
Merchant sceglie il livello; default = card a token controllata. Può diventare un agente dedicato (theming) se troppo grande per A5.
- **A6 compliance-engine** — esclusioni Art. 59 + calcolo finestra/scadenza per tipo prodotto + warning misconfig + logica rimborso (R8,R9,R10,R13,R15). **Uscita:** merchant configura esclusioni/finestre; item escluso si comporta secondo R9.
- **A6-bis operatività per stato ordine** *(confermata call Pizeta 2026-06-16 + `AUDIT-STATI-ORDINE.md`)* — comportamenti aggiuntivi, **tutti toggle per-shop** (app generica: ognuno decide). Dati già raccolti dal lookup: `displayFulfillmentStatus`, `deliveredAt`, `cancelledAt`, `financialStatus`.
1. **Ricevuta differenziata per stato** (`stateAwareEmail`, default ON): la ricevuta durevole (sempre inviata) include un blocco operativo diverso —
- *non evaso* → "ordine annullato, procederemo al rimborso";
- *spedito/consegnato* → istruzioni di reso (indirizzo, spese a carico cliente, prodotto integro, rimborso dopo il rientro).
2. **Auto-annullamento ordini non evasi** (`autoCancelUnfulfilled`, default OFF — irreversibile, opt-in): al recesso su ordine non evaso → `orderCancel` (refund+restock). Bonus: Shopify emette `orders/cancelled` → si ferma il bot di remarketing del merchant.
3. **Istruzioni di reso configurabili**: `returnAddress` (già in schema), `returnAtCustomerExpense` (default ON — Art. 57: informa + rende il cliente responsabile delle spese), `returnInstructions` (testo opzionale).
**Resta MANUALE per scelta Pizeta** (nessuna automazione richiesta): resi parziali + rimborso proporzionale (pannello Shopify), emissione rimborso (Shopify manda la sua mail), verifica data consegna se il corriere non passa l'evento a Shopify.
**Uscita:** merchant attiva/disattiva ciascun comportamento; il flusso si adatta allo stato dell'ordine.
### Fase 4 — Robustezza *(A7→A8 sequenziali, poi A9 audit)*
- **A7 i18n** — localizzazione IT/EN/DE/FR/ES (R14).
@@ -191,6 +199,37 @@ A0 → A1 → A2 → A3 → A4 → A5 → A6 → A7 → A8 → A9 → A10 → A1
---
## 4-ter. Piano consolidato — stato attuale + residuo *(agg. 2026-07-07)*
Vista unica: cosa è FATTO e cosa RESTA, con A6-bis (Pizeta) e i gap dell'audit
integrati. Dettaglio stato/commit nella memoria di progetto + git.
### ✅ Fatto (custom-grade, testato su pcrt-reso-test)
- **A0A1** fondamenta + spec compliance + copy deck.
- **A2** Theme App Extension (app block + app embed) + **modal** storefront (iframe, redesign de-AI, popover info).
- **A3** App Proxy: flusso guest 2-step + persistenza + **timestamp trasmissione** + audit (anti-enumeration).
- **A4** ricevuta durevole (nodemailer/Mailpit dev) + **dati per-shop dinamici** (nome/URL/link ordine da Shopify) + **template editabile vincolato** (oggetto/intro/nota + anteprima live + ripristino).
- **A5 (parziale)** admin Polaris: dashboard **Recessi**, **Impostazioni** (email/notifiche/regole), **Esclusioni** (CRUD). Theming: solo token base.
- **A6 (parziale)** engine finestra + esclusioni Art. 59 (dietro toggle, default OFF).
- **A8 (stub)** webhook GDPR + HMAC.
- **EXTRA (oltre il piano originale):** integrazione **Resi Shopify** (`returnCreate` su ordini evasi), **notifica merchant + tag** (toggle), **fix P1** (finestra sulla CONSEGNA, skip ordini annullati/rimborsati), **avviso ordini chiusi**, **audit stati ordine** (`AUDIT-STATI-ORDINE.md`).
### ⏳ Residuo (prioritizzato)
- **R1 — A6-bis operatività per stato ordine (Pizeta-confirmed)** — 3 toggle per-shop: `stateAwareEmail` (ricevuta differenziata non-evaso/spedito-consegnato), `autoCancelUnfulfilled` (annullo automatico ordini non evasi), `returnAtCustomerExpense`+`returnInstructions`+`returnAddress` (istruzioni reso, Art. 57). Dettaglio: sezione A6-bis.
- **R2 — A6 residuo copy compliance** — G7: promemoria rimborso 14gg + facoltà di trattenuta (Art. 56) nella notifica al merchant. *(G6 spese-reso assorbito da R1.)*
- **R3 — Deploy custom (go-live)** — Fly.io + Postgres prod + URL stabile + tunnel Cloudflare nominato + link install custom sui 2-3 store live. *(Serve per usarlo davvero; occhio deadline transfer pizeta-pharma-2 ~metà luglio 2026.)*
- **R4 — A9 hardening / QA-security** — rate-limit robusto, retry ricevuta fallita, idempotenza webhook, review avversariale (enumeration lookup, HMAC, PII), Protected Customer Data (approvazione Shopify per leggere `order.email` in prod).
- **R5 — A7 i18n** — IT/EN/DE/FR/ES.
- **R6 — A5 residuo theming** — motore 3 livelli completo (token no-code / eredita-tema Liquid / CSS custom).
- **R7 — Pubblica** — A10 billing + A11 submission App Store (registrazione Partner separata → non impatta le custom live).
### Ordine consigliato
`R1 → R2 → R3 (deploy) → R4 (hardening) → R5/R6 → R7`
R1/R2 chiudono valore-cliente + compliance copy; R3 mette live; R4 mette in sicurezza prima del traffico reale; R5/R6 rifiniscono; R7 quando si va public.
---
## 5. Milestone
- **M1 (Fase 01):** scaffold + contratto compliance. *Nulla di visibile, ma toglie rischio a tutto.*

View File

@@ -61,11 +61,45 @@ export function renderSubject(subjectTpl: string | null | undefined, vars: Email
return substPlain(tpl, vars).trim() || substPlain(DEFAULT_SUBJECT, vars);
}
export interface OperationalConfig {
state: "unfulfilled" | "shipped" | "delivered";
returnAddress?: string | null;
atCustomerExpense: boolean;
instructions?: string | null;
}
/** Blocco operativo per stato ordine (A6-bis): annullo (non evaso) o istruzioni reso. */
function renderOperationalBlock(op: OperationalConfig): string {
let inner: string;
if (op.state === "unfulfilled") {
inner =
"Il tuo ordine non risultava ancora spedito: procederemo all'annullamento e al rimborso. Non devi restituire nulla.";
} else {
const stateWord = op.state === "delivered" ? "consegnato" : "spedito";
const addr =
op.returnAddress && op.returnAddress.trim()
? `<strong>${escHtml(op.returnAddress.trim())}</strong>`
: "l'indirizzo che ti comunicheremo";
const spese = op.atCustomerExpense
? "Le spese di restituzione sono a tuo carico."
: "Le spese di restituzione sono a nostro carico.";
const instr =
op.instructions && op.instructions.trim()
? `<br>${nl2br(escHtml(op.instructions.trim()))}`
: "";
inner = `Il prodotto risulta ${stateWord}. Per ottenere il rimborso, restituisci la merce integra a: ${addr}. ${spese}${instr}<br>Il rimborso sara' disposto dopo il rientro della merce.`;
}
return `<tr><td style="padding:4px 32px 8px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="padding:14px 16px;background:#fff7e6;border:1px solid #ffe2a8;border-radius:8px;font-size:14px;line-height:1.6;color:#6a5518;">${inner}</td></tr></table>
</td></tr>`;
}
/** Corpo HTML fisso con intro/nota editabili inseriti. */
export function renderReceiptHtml(
vars: EmailVars,
introTpl: string | null | undefined,
noteTpl: string | null | undefined,
operational?: OperationalConfig | null,
): string {
const intro = richText((introTpl && introTpl.trim()) || DEFAULT_INTRO, vars);
const noteVal = noteTpl && noteTpl.trim() ? richText(noteTpl, vars) : "";
@@ -107,6 +141,7 @@ export function renderReceiptHtml(
<tr><td style="padding:12px 32px 4px;">
<p style="margin:0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Questa comunicazione costituisce la ricevuta su supporto durevole ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora indicate attestano il momento della trasmissione.</p>
</td></tr>
${operational ? renderOperationalBlock(operational) : ""}
${note}
<tr><td style="padding:18px 32px 24px;border-top:1px solid #ececec;">
<p style="margin:0 0 8px;font-size:12.5px;line-height:1.6;color:#999;">Ti invieremo separatamente le istruzioni per l'eventuale reso e i tempi di rimborso.</p>

View File

@@ -15,6 +15,7 @@ import {
renderReceiptHtml,
renderSubject,
type EmailVars,
type OperationalConfig,
} from "./emailTemplate";
function buildTransport() {
@@ -65,6 +66,7 @@ export async function sendWithdrawalReceipt(params: {
subject?: string | null;
intro?: string | null;
note?: string | null;
operational?: OperationalConfig | null;
}): Promise<ReceiptResult> {
const transport = buildTransport();
if (!transport) {
@@ -72,7 +74,12 @@ export async function sendWithdrawalReceipt(params: {
}
const subject = renderSubject(params.subject, params.vars);
const html = renderReceiptHtml(params.vars, params.intro, params.note);
const html = renderReceiptHtml(
params.vars,
params.intro,
params.note,
params.operational,
);
const text = htmlToText(html);
try {

View File

@@ -47,6 +47,12 @@ export const ERROR = {
generic: "Si è verificato un problema. Riprova tra poco.",
} as const;
// Avvisi informativi (non bloccanti).
export const NOTICE = {
orderClosed:
"Questo ordine risulta già annullato o rimborsato. Puoi comunque registrare il recesso; il negozio ti contatterà per eventuali dettagli.",
} as const;
// Schermata finale (dopo "Conferma recesso").
export function successMessage(
orderName: string,

View File

@@ -123,22 +123,25 @@ export interface MatchedOrder {
email: string; // email dell'ordine (per precompilazione)
createdAt: string;
orderUrl: string; // URL pagina di stato dell'ordine (link per il cliente)
fulfilledAt: string | null; // data ultima evasione (riferimento finestra), null se non evaso
fulfilledAt: string | null; // data ultima evasione (spedizione), null se non evaso
deliveredAt: string | null; // data di consegna (possesso fisico, Art. 52), null se non consegnato
cancelledAt: string | null; // data annullamento ordine, null altrimenti
financialStatus: string | null; // displayFinancialStatus (REFUNDED/VOIDED/PAID/...)
lineItems: Array<{ productId: string | null; tags: string[] }>;
}
// --- A6: finestra di recesso (deadline engine) ---------------------------
// Riferimento = data di evasione (consegna ~ ricezione beni) se disponibile,
// altrimenti data ordine (fallback conservativo). Scadenza = riferimento + giorni.
// Riferimento = data di CONSEGNA (possesso fisico, Art. 52), NON la spedizione.
// Se non consegnato -> la finestra non e' iniziata -> nessuna scadenza (non si
// blocca mai): il recesso resta ammesso (nasce dalla conclusione del contratto).
// NB: l'estensione a 12 mesi + 14gg per mancata informativa (Art. 49) NON e'
// gestita qui: l'app FORNISCE l'informativa, quindi vale il termine ordinario.
export function computeDeadline(
match: MatchedOrder,
windowDays: number,
): Date | null {
const ref = match.fulfilledAt || match.createdAt;
if (!ref) return null;
const d = new Date(ref);
if (!match.deliveredAt) return null;
const d = new Date(match.deliveredAt);
if (Number.isNaN(d.getTime())) return null;
d.setUTCDate(d.getUTCDate() + windowDays);
return d;
@@ -200,6 +203,23 @@ export function checkExclusions(
};
}
// --- A6-bis: stato operativo ordine --------------------------------------
export type OrderState = "unfulfilled" | "shipped" | "delivered" | "closed";
/** Stato per il flusso recesso (email differenziata + auto-annullo). */
export function orderState(match: MatchedOrder): OrderState {
if (
match.cancelledAt ||
match.financialStatus === "REFUNDED" ||
match.financialStatus === "VOIDED"
) {
return "closed";
}
if (match.deliveredAt) return "delivered";
if (match.fulfilledAt) return "shipped";
return "unfulfilled";
}
interface OrderLookupGraphQL {
data?: {
orders?: {
@@ -210,8 +230,20 @@ interface OrderLookupGraphQL {
email?: string | null;
createdAt?: string | null;
statusPageUrl?: string | null;
cancelledAt?: string | null;
displayFulfillmentStatus?: string | null;
fulfillments?: Array<{ createdAt?: string | null } | null> | null;
displayFinancialStatus?: string | null;
fulfillments?: Array<{
createdAt?: string | null;
events?: {
edges?: Array<{
node?: {
status?: string | null;
happenedAt?: string | null;
} | null;
} | null> | null;
} | null;
} | null> | null;
lineItems?: {
edges?: Array<{
node?: {
@@ -236,9 +268,19 @@ const ORDER_LOOKUP_QUERY = `#graphql
email
createdAt
statusPageUrl
cancelledAt
displayFulfillmentStatus
displayFinancialStatus
fulfillments(first: 10) {
createdAt
events(first: 25) {
edges {
node {
status
happenedAt
}
}
}
}
lineItems(first: 50) {
edges {
@@ -468,6 +510,51 @@ export async function tagOrderRecesso(
}
}
const ORDER_CANCEL_MUTATION = `#graphql
mutation recessoOrderCancel($id: ID!) {
orderCancel(orderId: $id, reason: CUSTOMER, refund: true, restock: true, notifyCustomer: false, staffNote: "Recesso art. 54-bis") {
job { id }
orderCancelUserErrors { field message }
}
}`;
interface OrderCancelGraphQL {
data?: {
orderCancel?: {
orderCancelUserErrors?: Array<{ message?: string | null }> | null;
} | null;
} | null;
}
/** Annulla l'ordine (recesso su ordine non evaso): refund + restock. Best-effort. */
export async function cancelOrder(
admin: AdminApiContext,
orderGid: string,
): Promise<{ ok: boolean; error?: string }> {
try {
const res = await admin.graphql(ORDER_CANCEL_MUTATION, {
variables: { id: orderGid },
});
const body = (await res.json()) as OrderCancelGraphQL;
const errs = body.data?.orderCancel?.orderCancelUserErrors ?? [];
if (errs.length) {
return {
ok: false,
error: errs
.map((e) => e?.message ?? "")
.filter(Boolean)
.join("; "),
};
}
return { ok: true };
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : "orderCancel fallito",
};
}
}
export async function lookupOrder(
admin: AdminApiContext,
orderInput: string,
@@ -503,6 +590,13 @@ export async function lookupOrder(
.map((f) => f?.createdAt)
.filter((d): d is string => !!d)
.sort();
const deliveryDates = (node.fulfillments ?? [])
.flatMap((f) => f?.events?.edges ?? [])
.map((e) => e?.node)
.filter((n): n is NonNullable<typeof n> => !!n)
.filter((n) => n.status === "DELIVERED" && !!n.happenedAt)
.map((n) => n.happenedAt as string)
.sort();
const lineItems = (node.lineItems?.edges ?? [])
.map((e) => e?.node)
.filter((n): n is NonNullable<typeof n> => !!n)
@@ -519,6 +613,11 @@ export async function lookupOrder(
fulfilledAt: fulfillmentDates.length
? fulfillmentDates[fulfillmentDates.length - 1]
: null,
deliveredAt: deliveryDates.length
? deliveryDates[deliveryDates.length - 1]
: null,
cancelledAt: node.cancelledAt ?? null,
financialStatus: node.displayFinancialStatus ?? null,
lineItems,
};
}
@@ -769,6 +868,21 @@ const PAGE_CSS = `
font-size: 0.92rem;
}
.error__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: currentColor; }
.notice {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(240, 170, 40, 0.14);
border: 1px solid rgba(240, 170, 40, 0.55);
border-left: 3px solid rgba(240, 170, 40, 0.95);
border-radius: var(--radius-sm);
padding: 12px 14px;
margin: 0 0 18px;
color: var(--text);
font-size: 0.9rem;
line-height: 1.5;
}
.notice__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: #e0a020; }
/* Riepilogo (step 3) */
.summary { margin: 18px 0 4px; }
@@ -856,6 +970,14 @@ function errorBanner(message?: string): string {
</div>`;
}
function noticeBanner(message?: string): string {
if (!message) return "";
return `<div class="notice" role="status">
<svg class="notice__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 5h2v2H9V5Zm0 4h2v6H9V9Z"/></svg>
<span>${escapeHtml(message)}</span>
</div>`;
}
/**
* Pulsante info "i" + popover nativo (mini-modal, zero JS via Popover API).
* Il popover vive nel top-layer; chiusura con Esc, click fuori o pulsante.
@@ -948,12 +1070,13 @@ export function renderStep2(data: {
customerName?: string;
statementText: string;
error?: string;
notice?: string;
}): string {
const customerName = data.customerName ?? "";
return renderShell(
stepLayout({
head: stepHead(2, `Ordine ${data.orderName}`),
body: `${errorBanner(data.error)}
body: `${errorBanner(data.error)}${noticeBanner(data.notice)}
<form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
<input type="hidden" name="intent" value="details">
<input type="hidden" name="orderId" value="${attr(data.orderId)}">

View File

@@ -47,6 +47,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
enforceWindow: settings?.enforceWindow ?? false,
windowDays: settings?.defaultWindowDays ?? 14,
enforceExclusions: settings?.enforceExclusions ?? false,
stateAwareEmail: settings?.stateAwareEmail ?? true,
autoCancelUnfulfilled: settings?.autoCancelUnfulfilled ?? false,
returnAtCustomerExpense: settings?.returnAtCustomerExpense ?? true,
returnAddress: settings?.returnAddress ?? "",
returnInstructions: settings?.returnInstructions ?? "",
};
};
@@ -70,6 +75,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
Math.max(1, Number(form.get("windowDays")) || 14),
),
enforceExclusions: form.get("enforceExclusions") === "true",
stateAwareEmail: form.get("stateAwareEmail") === "true",
autoCancelUnfulfilled: form.get("autoCancelUnfulfilled") === "true",
returnAtCustomerExpense: form.get("returnAtCustomerExpense") === "true",
returnAddress: String(form.get("returnAddress") ?? "").trim() || null,
returnInstructions:
String(form.get("returnInstructions") ?? "").trim() || null,
};
await db.settings.upsert({
@@ -98,6 +109,17 @@ export default function SettingsPage() {
const [enforceExclusions, setEnforceExclusions] = useState(
data.enforceExclusions,
);
const [stateAwareEmail, setStateAwareEmail] = useState(data.stateAwareEmail);
const [autoCancelUnfulfilled, setAutoCancelUnfulfilled] = useState(
data.autoCancelUnfulfilled,
);
const [returnAtCustomerExpense, setReturnAtCustomerExpense] = useState(
data.returnAtCustomerExpense,
);
const [returnAddress, setReturnAddress] = useState(data.returnAddress);
const [returnInstructions, setReturnInstructions] = useState(
data.returnInstructions,
);
const [showSaved, setShowSaved] = useState(false);
const saving = nav.state === "submitting";
@@ -127,6 +149,11 @@ export default function SettingsPage() {
fd.set("enforceWindow", String(enforceWindow));
fd.set("windowDays", windowDays);
fd.set("enforceExclusions", String(enforceExclusions));
fd.set("stateAwareEmail", String(stateAwareEmail));
fd.set("autoCancelUnfulfilled", String(autoCancelUnfulfilled));
fd.set("returnAtCustomerExpense", String(returnAtCustomerExpense));
fd.set("returnAddress", returnAddress);
fd.set("returnInstructions", returnInstructions);
submit(fd, { method: "post" });
};
@@ -275,6 +302,52 @@ export default function SettingsPage() {
</BlockStack>
</Card>
<Card>
<BlockStack gap="400">
<Text as="h2" variant="headingMd">
Reso e stato ordine
</Text>
<Checkbox
label="Adatta la ricevuta allo stato dell'ordine"
checked={stateAwareEmail}
onChange={setStateAwareEmail}
helpText="Non evaso → annullo+rimborso; spedito/consegnato → istruzioni di reso."
/>
<Checkbox
label="Annulla automaticamente gli ordini non ancora spediti"
checked={autoCancelUnfulfilled}
onChange={setAutoCancelUnfulfilled}
helpText="Al recesso, se l'ordine non è evaso: annullo + rimborso automatici. Irreversibile."
/>
<Checkbox
label="Spese di restituzione a carico del cliente (Art. 57)"
checked={returnAtCustomerExpense}
onChange={setReturnAtCustomerExpense}
/>
<TextField
label="Indirizzo per il reso"
value={returnAddress}
onChange={setReturnAddress}
autoComplete="off"
multiline={2}
placeholder="Via ..., CAP Città (PR)"
/>
<TextField
label="Istruzioni di reso (opzionale)"
value={returnInstructions}
onChange={setReturnInstructions}
autoComplete="off"
multiline={3}
helpText="Testo aggiuntivo mostrato nella ricevuta per ordini spediti/consegnati."
/>
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
</div>
</BlockStack>
</Card>
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">

View File

@@ -24,6 +24,7 @@ import {
import {
ERROR,
EXCLUSION_REASON,
NOTICE,
exclusionMessage,
statementTemplate,
successMessage,
@@ -32,6 +33,7 @@ import {
MVP_LOCALE,
checkExclusions,
checkRateLimit,
cancelOrder,
clientIp,
createShopifyReturn,
formatTransmittedAt,
@@ -41,6 +43,7 @@ import {
isValidEmail,
isWindowExpired,
lookupOrder,
orderState,
renderStep1,
renderStep2,
renderStep3,
@@ -195,12 +198,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
);
}
const orderClosed =
!!match.cancelledAt ||
match.financialStatus === "REFUNDED" ||
match.financialStatus === "VOIDED";
return htmlResponse(
renderStep2({
orderId: match.orderId,
orderName: match.orderName,
email: match.email,
statementText: statementTemplate(match.orderName),
notice: orderClosed ? NOTICE.orderClosed : undefined,
}),
);
}
@@ -360,6 +368,18 @@ export const action = async ({ request }: ActionFunctionArgs) => {
getShopInfo(admin),
]);
const shopName = shopInfo.name || shop.replace(/\.myshopify\.com$/, "");
// A6-bis: blocco operativo per stato ordine (se stateAwareEmail attivo).
const state = orderState(match);
const operational =
settings?.stateAwareEmail !== false &&
(state === "unfulfilled" || state === "shipped" || state === "delivered")
? {
state,
returnAddress: settings?.returnAddress,
atCustomerExpense: settings?.returnAtCustomerExpense ?? true,
instructions: settings?.returnInstructions,
}
: null;
const receipt = await sendWithdrawalReceipt({
to: email,
vars: {
@@ -374,6 +394,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
subject: settings?.emailSubject,
intro: settings?.emailIntro,
note: settings?.emailNote,
operational,
});
try {
if (receipt.ok) {
@@ -401,44 +422,76 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
// Integrazione Resi Shopify: crea un reso nativo per gli ordini evasi
// (best-effort; il recesso legale e' gia' registrato). Non evaso -> il
// (best-effort; il recesso legale e' gia' registrato). Ordini annullati/
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
// merchant gestisce annullo/rimborso.
let returnStatus: "created" | "no_returnable" | "error" = "error";
try {
const ret = await createShopifyReturn(admin, match.orderId);
returnStatus = ret.status;
if (ret.status === "created") {
await db.withdrawalRequest.update({
where: { id: created.id },
data: { shopifyReturnId: ret.returnId },
});
await db.auditLog.create({
data: {
shop,
event: "shopify_return_created",
detail: match.orderName,
},
});
} else if (ret.status === "no_returnable") {
await db.auditLog.create({
data: {
shop,
event: "shopify_return_skipped",
detail: "ordine non evaso o nulla da rendere",
},
});
} else {
console.error("[recesso] returnCreate:", ret.error);
await db.auditLog.create({
data: {
shop,
event: "shopify_return_failed",
detail: ret.error.slice(0, 200),
},
});
const orderClosed =
!!match.cancelledAt ||
match.financialStatus === "REFUNDED" ||
match.financialStatus === "VOIDED";
if (orderClosed) {
returnStatus = "no_returnable";
await db.auditLog.create({
data: {
shop,
event: "shopify_return_skipped",
detail: "ordine annullato o rimborsato",
},
});
} else {
try {
const ret = await createShopifyReturn(admin, match.orderId);
returnStatus = ret.status;
if (ret.status === "created") {
await db.withdrawalRequest.update({
where: { id: created.id },
data: { shopifyReturnId: ret.returnId },
});
await db.auditLog.create({
data: {
shop,
event: "shopify_return_created",
detail: match.orderName,
},
});
} else if (ret.status === "no_returnable") {
await db.auditLog.create({
data: {
shop,
event: "shopify_return_skipped",
detail: "ordine non evaso o nulla da rendere",
},
});
} else {
console.error("[recesso] returnCreate:", ret.error);
await db.auditLog.create({
data: {
shop,
event: "shopify_return_failed",
detail: ret.error.slice(0, 200),
},
});
}
} catch (e) {
console.error("[recesso] integrazione reso fallita:", e);
}
}
// A6-bis: auto-annullo ordini non evasi (se abilitato). refund + restock.
if (settings?.autoCancelUnfulfilled && state === "unfulfilled") {
try {
const c = await cancelOrder(admin, match.orderId);
await db.auditLog.create({
data: {
shop,
event: c.ok ? "order_auto_cancelled" : "order_auto_cancel_failed",
detail: c.ok ? match.orderName : (c.error ?? "").slice(0, 200),
},
});
} catch (e) {
console.error("[recesso] auto-annullo fallito:", e);
}
} catch (e) {
console.error("[recesso] integrazione reso fallita:", e);
}
// Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders.

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "autoCancelUnfulfilled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "returnAtCustomerExpense" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "returnInstructions" TEXT,
ADD COLUMN "stateAwareEmail" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -57,6 +57,10 @@ model Settings {
tagEnabled Boolean @default(true)
enforceWindow Boolean @default(false)
enforceExclusions Boolean @default(false)
stateAwareEmail Boolean @default(true)
autoCancelUnfulfilled Boolean @default(false)
returnAtCustomerExpense Boolean @default(true)
returnInstructions String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt