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
This commit is contained in:
118
AUDIT-STATI-ORDINE.md
Normal file
118
AUDIT-STATI-ORDINE.md
Normal 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.
|
||||||
@@ -123,22 +123,25 @@ export interface MatchedOrder {
|
|||||||
email: string; // email dell'ordine (per precompilazione)
|
email: string; // email dell'ordine (per precompilazione)
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
orderUrl: string; // URL pagina di stato dell'ordine (link per il cliente)
|
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[] }>;
|
lineItems: Array<{ productId: string | null; tags: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- A6: finestra di recesso (deadline engine) ---------------------------
|
// --- A6: finestra di recesso (deadline engine) ---------------------------
|
||||||
// Riferimento = data di evasione (consegna ~ ricezione beni) se disponibile,
|
// Riferimento = data di CONSEGNA (possesso fisico, Art. 52), NON la spedizione.
|
||||||
// altrimenti data ordine (fallback conservativo). Scadenza = riferimento + giorni.
|
// 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'
|
// 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.
|
// gestita qui: l'app FORNISCE l'informativa, quindi vale il termine ordinario.
|
||||||
export function computeDeadline(
|
export function computeDeadline(
|
||||||
match: MatchedOrder,
|
match: MatchedOrder,
|
||||||
windowDays: number,
|
windowDays: number,
|
||||||
): Date | null {
|
): Date | null {
|
||||||
const ref = match.fulfilledAt || match.createdAt;
|
if (!match.deliveredAt) return null;
|
||||||
if (!ref) return null;
|
const d = new Date(match.deliveredAt);
|
||||||
const d = new Date(ref);
|
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
d.setUTCDate(d.getUTCDate() + windowDays);
|
d.setUTCDate(d.getUTCDate() + windowDays);
|
||||||
return d;
|
return d;
|
||||||
@@ -210,8 +213,20 @@ interface OrderLookupGraphQL {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
statusPageUrl?: string | null;
|
statusPageUrl?: string | null;
|
||||||
|
cancelledAt?: string | null;
|
||||||
displayFulfillmentStatus?: 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?: {
|
lineItems?: {
|
||||||
edges?: Array<{
|
edges?: Array<{
|
||||||
node?: {
|
node?: {
|
||||||
@@ -236,9 +251,19 @@ const ORDER_LOOKUP_QUERY = `#graphql
|
|||||||
email
|
email
|
||||||
createdAt
|
createdAt
|
||||||
statusPageUrl
|
statusPageUrl
|
||||||
|
cancelledAt
|
||||||
displayFulfillmentStatus
|
displayFulfillmentStatus
|
||||||
|
displayFinancialStatus
|
||||||
fulfillments(first: 10) {
|
fulfillments(first: 10) {
|
||||||
createdAt
|
createdAt
|
||||||
|
events(first: 25) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
status
|
||||||
|
happenedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lineItems(first: 50) {
|
lineItems(first: 50) {
|
||||||
edges {
|
edges {
|
||||||
@@ -503,6 +528,13 @@ export async function lookupOrder(
|
|||||||
.map((f) => f?.createdAt)
|
.map((f) => f?.createdAt)
|
||||||
.filter((d): d is string => !!d)
|
.filter((d): d is string => !!d)
|
||||||
.sort();
|
.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 ?? [])
|
const lineItems = (node.lineItems?.edges ?? [])
|
||||||
.map((e) => e?.node)
|
.map((e) => e?.node)
|
||||||
.filter((n): n is NonNullable<typeof n> => !!n)
|
.filter((n): n is NonNullable<typeof n> => !!n)
|
||||||
@@ -519,6 +551,11 @@ export async function lookupOrder(
|
|||||||
fulfilledAt: fulfillmentDates.length
|
fulfilledAt: fulfillmentDates.length
|
||||||
? fulfillmentDates[fulfillmentDates.length - 1]
|
? fulfillmentDates[fulfillmentDates.length - 1]
|
||||||
: null,
|
: null,
|
||||||
|
deliveredAt: deliveryDates.length
|
||||||
|
? deliveryDates[deliveryDates.length - 1]
|
||||||
|
: null,
|
||||||
|
cancelledAt: node.cancelledAt ?? null,
|
||||||
|
financialStatus: node.displayFinancialStatus ?? null,
|
||||||
lineItems,
|
lineItems,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,44 +401,60 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Integrazione Resi Shopify: crea un reso nativo per gli ordini evasi
|
// 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.
|
// merchant gestisce annullo/rimborso.
|
||||||
let returnStatus: "created" | "no_returnable" | "error" = "error";
|
let returnStatus: "created" | "no_returnable" | "error" = "error";
|
||||||
try {
|
const orderClosed =
|
||||||
const ret = await createShopifyReturn(admin, match.orderId);
|
!!match.cancelledAt ||
|
||||||
returnStatus = ret.status;
|
match.financialStatus === "REFUNDED" ||
|
||||||
if (ret.status === "created") {
|
match.financialStatus === "VOIDED";
|
||||||
await db.withdrawalRequest.update({
|
if (orderClosed) {
|
||||||
where: { id: created.id },
|
returnStatus = "no_returnable";
|
||||||
data: { shopifyReturnId: ret.returnId },
|
await db.auditLog.create({
|
||||||
});
|
data: {
|
||||||
await db.auditLog.create({
|
shop,
|
||||||
data: {
|
event: "shopify_return_skipped",
|
||||||
shop,
|
detail: "ordine annullato o rimborsato",
|
||||||
event: "shopify_return_created",
|
},
|
||||||
detail: match.orderName,
|
});
|
||||||
},
|
} else {
|
||||||
});
|
try {
|
||||||
} else if (ret.status === "no_returnable") {
|
const ret = await createShopifyReturn(admin, match.orderId);
|
||||||
await db.auditLog.create({
|
returnStatus = ret.status;
|
||||||
data: {
|
if (ret.status === "created") {
|
||||||
shop,
|
await db.withdrawalRequest.update({
|
||||||
event: "shopify_return_skipped",
|
where: { id: created.id },
|
||||||
detail: "ordine non evaso o nulla da rendere",
|
data: { shopifyReturnId: ret.returnId },
|
||||||
},
|
});
|
||||||
});
|
await db.auditLog.create({
|
||||||
} else {
|
data: {
|
||||||
console.error("[recesso] returnCreate:", ret.error);
|
shop,
|
||||||
await db.auditLog.create({
|
event: "shopify_return_created",
|
||||||
data: {
|
detail: match.orderName,
|
||||||
shop,
|
},
|
||||||
event: "shopify_return_failed",
|
});
|
||||||
detail: ret.error.slice(0, 200),
|
} 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.
|
// Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders.
|
||||||
|
|||||||
Reference in New Issue
Block a user