From a6a1900287e98ede537e256aa93107ae1a03a961 Mon Sep 17 00:00:00 2001 From: tommaso Date: Tue, 7 Jul 2026 16:16:22 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1 --- CHECKLIST-COMPLIANCE-MERCHANT.md | 43 +++++++++++ app/app/lib/emailTemplate.ts | 35 +++++++++ app/app/lib/mailer.server.ts | 9 ++- app/app/lib/recesso.server.ts | 62 ++++++++++++++++ app/app/routes/app.settings.tsx | 73 +++++++++++++++++++ app/app/routes/proxy.tsx | 31 ++++++++ .../migration.sql | 5 ++ app/prisma/schema.prisma | 4 + 8 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 CHECKLIST-COMPLIANCE-MERCHANT.md create mode 100644 app/prisma/migrations/20260707141105_a6bis_state_ops/migration.sql diff --git a/CHECKLIST-COMPLIANCE-MERCHANT.md b/CHECKLIST-COMPLIANCE-MERCHANT.md new file mode 100644 index 0000000..125e0e9 --- /dev/null +++ b/CHECKLIST-COMPLIANCE-MERCHANT.md @@ -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`. diff --git a/app/app/lib/emailTemplate.ts b/app/app/lib/emailTemplate.ts index 9e233a5..2fd13e4 100644 --- a/app/app/lib/emailTemplate.ts +++ b/app/app/lib/emailTemplate.ts @@ -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() + ? `${escHtml(op.returnAddress.trim())}` + : "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() + ? `
${nl2br(escHtml(op.instructions.trim()))}` + : ""; + inner = `Il prodotto risulta ${stateWord}. Per ottenere il rimborso, restituisci la merce integra a: ${addr}. ${spese}${instr}
Il rimborso sara' disposto dopo il rientro della merce.`; + } + return ` +
${inner}
+`; +} + /** 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(

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.

+${operational ? renderOperationalBlock(operational) : ""} ${note}

Ti invieremo separatamente le istruzioni per l'eventuale reso e i tempi di rimborso.

diff --git a/app/app/lib/mailer.server.ts b/app/app/lib/mailer.server.ts index 1e6d13f..f3feadc 100644 --- a/app/app/lib/mailer.server.ts +++ b/app/app/lib/mailer.server.ts @@ -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 { 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 { diff --git a/app/app/lib/recesso.server.ts b/app/app/lib/recesso.server.ts index 617cd78..cca9e56 100644 --- a/app/app/lib/recesso.server.ts +++ b/app/app/lib/recesso.server.ts @@ -203,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?: { @@ -493,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, diff --git a/app/app/routes/app.settings.tsx b/app/app/routes/app.settings.tsx index c97f1aa..a40e91f 100644 --- a/app/app/routes/app.settings.tsx +++ b/app/app/routes/app.settings.tsx @@ -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() { + + + + Reso e stato ordine + + + + + + +
+ +
+
+
+ diff --git a/app/app/routes/proxy.tsx b/app/app/routes/proxy.tsx index 38bcbd0..5e480c4 100644 --- a/app/app/routes/proxy.tsx +++ b/app/app/routes/proxy.tsx @@ -33,6 +33,7 @@ import { MVP_LOCALE, checkExclusions, checkRateLimit, + cancelOrder, clientIp, createShopifyReturn, formatTransmittedAt, @@ -42,6 +43,7 @@ import { isValidEmail, isWindowExpired, lookupOrder, + orderState, renderStep1, renderStep2, renderStep3, @@ -366,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: { @@ -380,6 +394,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { subject: settings?.emailSubject, intro: settings?.emailIntro, note: settings?.emailNote, + operational, }); try { if (receipt.ok) { @@ -463,6 +478,22 @@ export const action = async ({ request }: ActionFunctionArgs) => { } } + // 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); + } + } + // Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders. if (settings?.tagEnabled) { try { diff --git a/app/prisma/migrations/20260707141105_a6bis_state_ops/migration.sql b/app/prisma/migrations/20260707141105_a6bis_state_ops/migration.sql new file mode 100644 index 0000000..de1551a --- /dev/null +++ b/app/prisma/migrations/20260707141105_a6bis_state_ops/migration.sql @@ -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; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 62ff036..97127af 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -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