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
This commit is contained in:
2026-07-07 16:16:22 +02:00
parent 945f1a6230
commit a6a1900287
8 changed files with 261 additions and 1 deletions

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`.

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

@@ -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,

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

@@ -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 {

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