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

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