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:
43
CHECKLIST-COMPLIANCE-MERCHANT.md
Normal file
43
CHECKLIST-COMPLIANCE-MERCHANT.md
Normal 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`.
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user