From 3968d34011128d704d6927aabc6768f2a7ce5aad Mon Sep 17 00:00:00 2001 From: tommaso Date: Tue, 7 Jul 2026 15:06:38 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1 --- AUDIT-STATI-ORDINE.md | 118 ++++++++++++++++++++++++++++++++++ app/app/lib/recesso.server.ts | 51 +++++++++++++-- app/app/routes/proxy.tsx | 86 +++++++++++++++---------- 3 files changed, 213 insertions(+), 42 deletions(-) create mode 100644 AUDIT-STATI-ORDINE.md diff --git a/AUDIT-STATI-ORDINE.md b/AUDIT-STATI-ORDINE.md new file mode 100644 index 0000000..fb49483 --- /dev/null +++ b/AUDIT-STATI-ORDINE.md @@ -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. diff --git a/app/app/lib/recesso.server.ts b/app/app/lib/recesso.server.ts index 83c4ead..8a71e30 100644 --- a/app/app/lib/recesso.server.ts +++ b/app/app/lib/recesso.server.ts @@ -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; @@ -210,8 +213,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 +251,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 { @@ -503,6 +528,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 => !!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 => !!n) @@ -519,6 +551,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, }; } diff --git a/app/app/routes/proxy.tsx b/app/app/routes/proxy.tsx index 59f61dd..f4552be 100644 --- a/app/app/routes/proxy.tsx +++ b/app/app/routes/proxy.tsx @@ -401,44 +401,60 @@ 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); } - } catch (e) { - console.error("[recesso] integrazione reso fallita:", e); } // Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders.