Resi Shopify + notifiche merchant + A6 (finestra/esclusioni) + dashboard recessi

Integrazione Resi Shopify:
- Alla conferma del recesso, crea un Reso nativo (returnCreate) per gli ordini
  evasi -> gestione nella UI Resi nativa. Ordini non evasi: skip (merchant gestisce
  annullo/rimborso). Query via order.fulfillments (returnableFulfillments non
  esiste nella API 2026-04). Campo WithdrawalRequest.shopifyReturnId.

Notifiche al merchant (dietro toggle Settings):
- Email di notifica a ogni recesso (sendMerchantNotification) + tag "Recesso"
  sull'ordine (tagsAdd, scope write_orders). Toggle notifyEnabled/notifyEmail/tagEnabled.

A6 - completezza compliance (dietro toggle, default OFF, non tocca il flusso testato):
- Finestra 14gg: computeDeadline/isWindowExpired (rif = data evasione o ordine +
  defaultWindowDays). Esclusioni Art. 59: checkExclusions su regole ExclusionRule
  (ALL/PRODUCT/TAG). lookupOrder esteso (fulfillments + lineItems product/tags).
  checkCompliance() aggancia lookup+confirm. Toggle enforceWindow/enforceExclusions.
- Pagina admin Esclusioni (CRUD regole) + sezione Regole di recesso in Impostazioni.

Dashboard: pagina Recessi (registro legale read-only). NavMenu: Recessi/Impostazioni/Esclusioni.
Scope: +write_returns,+write_orders. Editor email vincolato (oggetto/intro/nota + anteprima).

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 14:47:07 +02:00
parent d140d90629
commit 2c8c2a7429
14 changed files with 1026 additions and 18 deletions

42
TEST-CHECKLIST.md Normal file
View File

@@ -0,0 +1,42 @@
# Test checklist - Notifica merchant + Tag ordine (+ Resi Shopify)
Da eseguire dopo lo sviluppo di questi comportamenti.
## 1. Riavvia dev (Prisma + nuovo scope write_orders cambiati)
```
q
shopify app dev --tunnel-url https://miscellaneous-connections-harvest-chronicle.trycloudflare.com:3458
```
## 2. Ri-concedi lo scope write_orders
Apri l'app nell'admin di pcrt-reso-test -> approva il nuovo permesso (ordini).
Se non lo chiede: disinstalla e reinstalla l'app.
Verifica scope concessi:
```
docker exec recesso-pg psql -U postgres -d recesso -c "SELECT scope FROM \"Session\";"
```
Atteso: read_orders,read_products,write_returns,write_orders
## 3. Configura in Admin -> Impostazioni
- Attiva "Invia email di notifica a ogni recesso"
- Metti un'email in "Email notifiche"
- Attiva "Aggiungi il tag 'Recesso' all'ordine"
- Salva
## 4. Esegui recesso dallo storefront
- Una volta su ordine EVASO
- Una volta su ordine NON evaso
## 5. Verifica
- Mailpit (http://localhost:18025): 2 email -> ricevuta cliente + notifica merchant
- Ordine (Shopify admin): tag "Recesso" presente (filtrabile in lista ordini)
- Ordine evaso: Reso creato (Ordini -> apri ordine -> blocco Reso)
- Audit:
```
docker exec recesso-pg psql -U postgres -d recesso -c "SELECT event, LEFT(detail,60) as detail, \"createdAt\" FROM \"AuditLog\" ORDER BY \"createdAt\" DESC LIMIT 10;"
```
Attesi: order_tagged, merchant_notified, shopify_return_created (evaso) / shopify_return_skipped (non evaso)
## Note
- I toggle in Impostazioni disattivano singolarmente notifica email e tag.
- Il recesso legale (record + ricevuta cliente) e' indipendente: resta valido anche se tag/notifica/reso falliscono.

View File

@@ -91,3 +91,87 @@ export async function sendWithdrawalReceipt(params: {
}; };
} }
} }
function escM(s: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Notifica al merchant di una nuova richiesta di recesso. Non lancia mai.
* `returnStatus` riflette l'esito dell'integrazione Resi (per suggerire l'azione).
*/
export async function sendMerchantNotification(params: {
to: string;
orderName: string;
customerName: string;
customerEmail: string;
orderUrl: string;
transmittedAt: string;
returnStatus: "created" | "no_returnable" | "error";
}): Promise<ReceiptResult> {
const transport = buildTransport();
if (!transport) {
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
}
const actionLine =
params.returnStatus === "created"
? "E' stato creato un reso nell'ordine: gestiscilo dalla pagina dell'ordine."
: params.returnStatus === "no_returnable"
? "L'ordine non risulta evaso: valuta annullamento o rimborso."
: "Reso non creato automaticamente: verifica manualmente l'ordine.";
const orderBtn = /^https?:\/\//i.test(params.orderUrl)
? `<p style="margin:16px 0 0;"><a href="${escM(params.orderUrl)}" style="display:inline-block;padding:10px 18px;background:#1a1a1a;color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">Apri l'ordine</a></p>`
: "";
const subject = `Nuovo recesso - Ordine ${params.orderName}`;
const html = `<!doctype html><html lang="it"><head><meta charset="utf-8"></head>
<body style="margin:0;padding:24px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="width:100%;max-width:520px;background:#fff;border:1px solid #e5e5e5;border-radius:12px;">
<tr><td style="padding:24px 28px;">
<h1 style="margin:0 0 12px;font-size:19px;">Nuova richiesta di recesso</h1>
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#3a3a3a;">Un cliente ha esercitato il diritto di recesso.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f6f7;border-radius:8px;"><tr><td style="padding:14px 16px;font-size:14px;line-height:1.9;color:#444;">
<div><span style="color:#777;">Ordine:</span> <strong>${escM(params.orderName)}</strong></div>
<div><span style="color:#777;">Cliente:</span> <strong>${escM(params.customerName)}</strong></div>
<div><span style="color:#777;">Email:</span> ${escM(params.customerEmail)}</div>
<div><span style="color:#777;">Trasmesso:</span> ${escM(params.transmittedAt)}</div>
</td></tr></table>
<p style="margin:16px 0 0;font-size:14px;line-height:1.6;color:#3a3a3a;">${actionLine}</p>
${orderBtn}
</td></tr></table>
</td></tr></table>
</body></html>`;
const text = [
"Nuova richiesta di recesso",
`Ordine: ${params.orderName}`,
`Cliente: ${params.customerName} (${params.customerEmail})`,
`Trasmesso: ${params.transmittedAt}`,
actionLine,
/^https?:\/\//i.test(params.orderUrl) ? params.orderUrl : "",
]
.filter(Boolean)
.join("\n");
try {
const info = await transport.sendMail({
from: process.env.MAIL_FROM ?? "no-reply@localhost",
to: params.to,
subject,
text,
html,
});
return { ok: true, messageId: info.messageId };
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : "invio notifica fallito",
};
}
}

View File

@@ -101,6 +101,7 @@ export const EXCLUSION_REASON = {
PERISHABLE: "prodotto deperibile o a rapida scadenza", PERISHABLE: "prodotto deperibile o a rapida scadenza",
HYGIENE: HYGIENE:
"prodotto sigillato, aperto dopo la consegna, non restituibile per motivi igienici o di salute", "prodotto sigillato, aperto dopo la consegna, non restituibile per motivi igienici o di salute",
OTHER: "prodotto escluso dal diritto di recesso",
} as const; } as const;
export function exclusionMessage(motivoEsclusione: string): string { export function exclusionMessage(motivoEsclusione: string): string {

View File

@@ -15,6 +15,7 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { AdminApiContext } from "@shopify/shopify-app-remix/server"; import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
import type { ExclusionRule } from "@prisma/client";
import { FIELD, INFO_TITLE, INFO_BODY, CONFIRM_LABEL, PAGE_TITLE } from "./recesso.copy"; import { FIELD, INFO_TITLE, INFO_BODY, CONFIRM_LABEL, PAGE_TITLE } from "./recesso.copy";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -122,6 +123,81 @@ 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
lineItems: Array<{ productId: string | null; tags: string[] }>;
}
// --- A6: finestra di recesso (deadline engine) ---------------------------
// Riferimento = data di evasione (consegna ~ ricezione beni) se disponibile,
// altrimenti data ordine (fallback conservativo). Scadenza = riferimento + giorni.
// 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.
export function computeDeadline(
match: MatchedOrder,
windowDays: number,
): Date | null {
const ref = match.fulfilledAt || match.createdAt;
if (!ref) return null;
const d = new Date(ref);
if (Number.isNaN(d.getTime())) return null;
d.setUTCDate(d.getUTCDate() + windowDays);
return d;
}
export function isWindowExpired(
match: MatchedOrder,
windowDays: number,
now: Date,
): boolean {
const deadline = computeDeadline(match, windowDays);
return deadline ? now.getTime() > deadline.getTime() : false;
}
// --- A6: esclusioni Art. 59 ----------------------------------------------
export interface ExclusionCheck {
fullyExcluded: boolean; // tutte le righe escluse -> recesso non ammesso
anyExcluded: boolean; // almeno una riga esclusa -> recesso parziale
reasons: string[]; // ragioni distinte (ExclusionReason)
}
function ruleMatchesLine(
rule: ExclusionRule,
li: { productId: string | null; tags: string[] },
): boolean {
switch (rule.scope) {
case "ALL":
return true;
case "PRODUCT":
return !!rule.targetId && li.productId === rule.targetId;
case "TAG":
return !!rule.targetId && li.tags.includes(rule.targetId);
default:
return false; // COLLECTION non supportato in MVP
}
}
export function checkExclusions(
match: MatchedOrder,
rules: ExclusionRule[],
): ExclusionCheck {
const active = rules.filter((r) => r.active);
if (!active.length || !match.lineItems.length) {
return { fullyExcluded: false, anyExcluded: false, reasons: [] };
}
const reasons = new Set<string>();
let excluded = 0;
for (const li of match.lineItems) {
const rule = active.find((r) => ruleMatchesLine(r, li));
if (rule) {
excluded++;
reasons.add(rule.reason);
}
}
return {
fullyExcluded: excluded === match.lineItems.length,
anyExcluded: excluded > 0,
reasons: [...reasons],
};
} }
interface OrderLookupGraphQL { interface OrderLookupGraphQL {
@@ -134,6 +210,16 @@ interface OrderLookupGraphQL {
email?: string | null; email?: string | null;
createdAt?: string | null; createdAt?: string | null;
statusPageUrl?: string | null; statusPageUrl?: string | null;
displayFulfillmentStatus?: string | null;
fulfillments?: Array<{ createdAt?: string | null } | null> | null;
lineItems?: {
edges?: Array<{
node?: {
quantity?: number | null;
product?: { id?: string | null; tags?: string[] | null } | null;
} | null;
} | null> | null;
} | null;
} | null; } | null;
} | null> | null; } | null> | null;
} | null; } | null;
@@ -150,6 +236,21 @@ const ORDER_LOOKUP_QUERY = `#graphql
email email
createdAt createdAt
statusPageUrl statusPageUrl
displayFulfillmentStatus
fulfillments(first: 10) {
createdAt
}
lineItems(first: 50) {
edges {
node {
quantity
product {
id
tags
}
}
}
}
} }
} }
} }
@@ -209,6 +310,164 @@ export async function getShopInfo(admin: AdminApiContext): Promise<ShopInfo> {
} }
} }
// --- Integrazione Resi Shopify -------------------------------------------
const RETURNABLE_QUERY = `#graphql
query recessoOrderFulfillments($orderId: ID!) {
order(id: $orderId) {
fulfillments(first: 10) {
fulfillmentLineItems(first: 50) {
edges {
node {
id
quantity
}
}
}
}
}
}`;
const RETURN_CREATE_MUTATION = `#graphql
mutation recessoReturnCreate($returnInput: ReturnInput!) {
returnCreate(returnInput: $returnInput) {
return { id status }
userErrors { field message }
}
}`;
interface ReturnableGraphQL {
data?: {
order?: {
fulfillments?: Array<{
fulfillmentLineItems?: {
edges?: Array<{
node?: { id?: string | null; quantity?: number | null } | null;
} | null> | null;
} | null;
} | null> | null;
} | null;
} | null;
}
interface ReturnCreateGraphQL {
data?: {
returnCreate?: {
return?: { id?: string | null; status?: string | null } | null;
userErrors?: Array<{ field?: string[] | null; message?: string | null }> | null;
} | null;
} | null;
}
export type ReturnCreation =
| { status: "created"; returnId: string }
| { status: "no_returnable" }
| { status: "error"; error: string };
/**
* Crea un Reso Shopify nativo per l'ordine (solo articoli evasi). Best-effort:
* il recesso legale e' gia' registrato a prescindere. Ordini non evasi -> nessun
* reso (il merchant gestisce annullo/rimborso).
*/
export async function createShopifyReturn(
admin: AdminApiContext,
orderGid: string,
): Promise<ReturnCreation> {
try {
const qRes = await admin.graphql(RETURNABLE_QUERY, {
variables: { orderId: orderGid },
});
const qBody = (await qRes.json()) as ReturnableGraphQL;
const returnLineItems: Array<{
fulfillmentLineItemId: string;
quantity: number;
returnReason: string;
returnReasonNote: string;
}> = [];
for (const f of qBody.data?.order?.fulfillments ?? []) {
for (const liEdge of f?.fulfillmentLineItems?.edges ?? []) {
const flId = liEdge?.node?.id;
const qty = liEdge?.node?.quantity ?? 0;
if (flId && qty > 0) {
returnLineItems.push({
fulfillmentLineItemId: flId,
quantity: qty,
returnReason: "OTHER",
returnReasonNote:
"Recesso ai sensi dell'art. 54-bis del Codice del Consumo",
});
}
}
}
if (!returnLineItems.length) return { status: "no_returnable" };
const mRes = await admin.graphql(RETURN_CREATE_MUTATION, {
variables: { returnInput: { orderId: orderGid, returnLineItems } },
});
const mBody = (await mRes.json()) as ReturnCreateGraphQL;
const errs = mBody.data?.returnCreate?.userErrors ?? [];
if (errs.length) {
return {
status: "error",
error: errs
.map((e) => e?.message ?? "")
.filter(Boolean)
.join("; "),
};
}
const returnId = mBody.data?.returnCreate?.return?.id;
if (!returnId) return { status: "error", error: "returnCreate: nessun id" };
return { status: "created", returnId };
} catch (e) {
return {
status: "error",
error: e instanceof Error ? e.message : "returnCreate fallito",
};
}
}
const ORDER_TAG_MUTATION = `#graphql
mutation recessoTagAdd($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) {
userErrors { field message }
}
}`;
interface TagsAddGraphQL {
data?: {
tagsAdd?: {
userErrors?: Array<{ message?: string | null }> | null;
} | null;
} | null;
}
/** Aggiunge il tag "Recesso" all'ordine (visibilita' merchant, filtrabile). Best-effort. */
export async function tagOrderRecesso(
admin: AdminApiContext,
orderGid: string,
): Promise<{ ok: boolean; error?: string }> {
try {
const res = await admin.graphql(ORDER_TAG_MUTATION, {
variables: { id: orderGid, tags: ["Recesso"] },
});
const body = (await res.json()) as TagsAddGraphQL;
const errs = body.data?.tagsAdd?.userErrors ?? [];
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 : "tagsAdd fallito",
};
}
}
export async function lookupOrder( export async function lookupOrder(
admin: AdminApiContext, admin: AdminApiContext,
orderInput: string, orderInput: string,
@@ -240,12 +499,27 @@ export async function lookupOrder(
node.name.toLowerCase() === normalized.toLowerCase(); node.name.toLowerCase() === normalized.toLowerCase();
const emailMatch = (node.email ?? "").trim().toLowerCase() === email; const emailMatch = (node.email ?? "").trim().toLowerCase() === email;
if (nameMatch && emailMatch) { if (nameMatch && emailMatch) {
const fulfillmentDates = (node.fulfillments ?? [])
.map((f) => f?.createdAt)
.filter((d): d is string => !!d)
.sort();
const lineItems = (node.lineItems?.edges ?? [])
.map((e) => e?.node)
.filter((n): n is NonNullable<typeof n> => !!n)
.map((n) => ({
productId: n.product?.id ?? null,
tags: n.product?.tags ?? [],
}));
return { return {
orderId: node.id, orderId: node.id,
orderName: node.name, orderName: node.name,
email: node.email ?? emailInput.trim(), email: node.email ?? emailInput.trim(),
createdAt: node.createdAt ?? "", createdAt: node.createdAt ?? "",
orderUrl: node.statusPageUrl ?? "", orderUrl: node.statusPageUrl ?? "",
fulfilledAt: fulfillmentDates.length
? fulfillmentDates[fulfillmentDates.length - 1]
: null,
lineItems,
}; };
} }
} }

View File

@@ -0,0 +1,233 @@
import { useState } from "react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import type { ExclusionReason, ExclusionScope } from "@prisma/client";
import {
useActionData,
useLoaderData,
useNavigation,
useSubmit,
} from "@remix-run/react";
import {
Page,
Layout,
Card,
Select,
TextField,
Button,
Banner,
Text,
Badge,
BlockStack,
InlineStack,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
const SCOPE_OPTIONS = [
{ label: "Tag prodotto", value: "TAG" },
{ label: "Prodotto (ID)", value: "PRODUCT" },
{ label: "Tutto l'ordine", value: "ALL" },
];
const REASON_OPTIONS = [
{ label: "Su misura / personalizzato", value: "CUSTOM" },
{ label: "Deperibile", value: "PERISHABLE" },
{ label: "Sigillato / igiene", value: "HYGIENE" },
{ label: "Altro", value: "OTHER" },
];
const SCOPE_LABEL: Record<string, string> = {
TAG: "Tag",
PRODUCT: "Prodotto",
ALL: "Tutto l'ordine",
COLLECTION: "Collezione",
};
const REASON_LABEL: Record<string, string> = {
CUSTOM: "Su misura",
PERISHABLE: "Deperibile",
HYGIENE: "Igiene",
OTHER: "Altro",
};
const VALID_SCOPES = ["ALL", "PRODUCT", "TAG"];
const VALID_REASONS = ["CUSTOM", "PERISHABLE", "HYGIENE", "OTHER"];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const rules = await db.exclusionRule.findMany({
where: { shop: session.shop },
orderBy: { createdAt: "desc" },
});
return { rules };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const form = await request.formData();
const intent = String(form.get("intent") ?? "");
if (intent === "delete") {
const id = String(form.get("id") ?? "");
if (id) {
await db.exclusionRule.deleteMany({ where: { id, shop: session.shop } });
}
return { ok: true, error: null };
}
if (intent === "add") {
const scopeRaw = String(form.get("scope") ?? "TAG");
const reasonRaw = String(form.get("reason") ?? "OTHER");
const target = String(form.get("target") ?? "").trim();
const scope = (
VALID_SCOPES.includes(scopeRaw) ? scopeRaw : "TAG"
) as ExclusionScope;
const reason = (
VALID_REASONS.includes(reasonRaw) ? reasonRaw : "OTHER"
) as ExclusionReason;
if (scope !== "ALL" && !target) {
return { ok: false, error: "Inserisci il valore (tag o ID prodotto)." };
}
await db.exclusionRule.create({
data: {
shop: session.shop,
scope,
reason,
targetId: scope === "ALL" ? null : target,
active: true,
},
});
return { ok: true, error: null };
}
return { ok: false, error: "Azione sconosciuta." };
};
export default function ExclusionsPage() {
const { rules } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const submit = useSubmit();
const nav = useNavigation();
const [scope, setScope] = useState("TAG");
const [target, setTarget] = useState("");
const [reason, setReason] = useState("HYGIENE");
const busy = nav.state !== "idle";
const add = () => {
const fd = new FormData();
fd.set("intent", "add");
fd.set("scope", scope);
fd.set("target", target);
fd.set("reason", reason);
submit(fd, { method: "post" });
setTarget("");
};
const del = (id: string) => {
const fd = new FormData();
fd.set("intent", "delete");
fd.set("id", id);
submit(fd, { method: "post" });
};
return (
<Page>
<TitleBar title="Esclusioni Art. 59" />
<Layout>
<Layout.Section>
<BlockStack gap="400">
{actionData && actionData.ok === false && actionData.error ? (
<Banner tone="critical">{actionData.error}</Banner>
) : null}
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Aggiungi esclusione
</Text>
<Text as="p" tone="subdued">
Prodotti non soggetti a recesso (art. 59). Modo più semplice:
tagga i prodotti (es. "no-recesso") e crea una regola Tag. Il
blocco si applica solo se attivo nelle Impostazioni.
</Text>
</BlockStack>
<InlineStack gap="300" align="start" blockAlign="end" wrap>
<Select
label="Tipo"
options={SCOPE_OPTIONS}
value={scope}
onChange={setScope}
/>
{scope !== "ALL" ? (
<TextField
label={scope === "TAG" ? "Tag prodotto" : "ID prodotto"}
value={target}
onChange={setTarget}
autoComplete="off"
placeholder={
scope === "TAG" ? "no-recesso" : "gid://shopify/Product/…"
}
/>
) : null}
<Select
label="Motivo"
options={REASON_OPTIONS}
value={reason}
onChange={setReason}
/>
<Button variant="primary" onClick={add} loading={busy}>
Aggiungi
</Button>
</InlineStack>
</BlockStack>
</Card>
<Card>
<BlockStack gap="300">
<Text as="h2" variant="headingMd">
Regole attive ({rules.length})
</Text>
{rules.length === 0 ? (
<Text as="p" tone="subdued">
Nessuna regola.
</Text>
) : (
<BlockStack gap="200">
{rules.map((r) => (
<InlineStack
key={r.id}
align="space-between"
blockAlign="center"
>
<InlineStack gap="200" blockAlign="center" wrap>
<Badge>{SCOPE_LABEL[r.scope] ?? r.scope}</Badge>
{r.targetId ? (
<Text as="span">{r.targetId}</Text>
) : null}
<Badge tone="info">
{REASON_LABEL[r.reason] ?? r.reason}
</Badge>
</InlineStack>
<Button
variant="plain"
tone="critical"
onClick={() => del(r.id)}
>
Elimina
</Button>
</InlineStack>
))}
</BlockStack>
)}
</BlockStack>
</Card>
</BlockStack>
</Layout.Section>
</Layout>
</Page>
);
}

View File

@@ -18,6 +18,7 @@ import {
BlockStack, BlockStack,
InlineStack, InlineStack,
Badge, Badge,
Checkbox,
} from "@shopify/polaris"; } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react"; import { TitleBar } from "@shopify/app-bridge-react";
@@ -40,6 +41,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
subject: settings?.emailSubject ?? DEFAULT_SUBJECT, subject: settings?.emailSubject ?? DEFAULT_SUBJECT,
intro: settings?.emailIntro ?? DEFAULT_INTRO, intro: settings?.emailIntro ?? DEFAULT_INTRO,
note: settings?.emailNote ?? DEFAULT_NOTE, note: settings?.emailNote ?? DEFAULT_NOTE,
notifyEnabled: settings?.notifyEnabled ?? true,
notifyEmail: settings?.notifyEmail ?? "",
tagEnabled: settings?.tagEnabled ?? true,
enforceWindow: settings?.enforceWindow ?? false,
windowDays: settings?.defaultWindowDays ?? 14,
enforceExclusions: settings?.enforceExclusions ?? false,
}; };
}; };
@@ -50,19 +57,25 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const intro = String(form.get("intro") ?? "").trim(); const intro = String(form.get("intro") ?? "").trim();
const note = String(form.get("note") ?? "").trim(); const note = String(form.get("note") ?? "").trim();
const data = {
emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null,
emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null,
emailNote: note || null,
notifyEnabled: form.get("notifyEnabled") === "true",
notifyEmail: String(form.get("notifyEmail") ?? "").trim() || null,
tagEnabled: form.get("tagEnabled") === "true",
enforceWindow: form.get("enforceWindow") === "true",
defaultWindowDays: Math.min(
365,
Math.max(1, Number(form.get("windowDays")) || 14),
),
enforceExclusions: form.get("enforceExclusions") === "true",
};
await db.settings.upsert({ await db.settings.upsert({
where: { shop: session.shop }, where: { shop: session.shop },
create: { create: { shop: session.shop, ...data },
shop: session.shop, update: data,
emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null,
emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null,
emailNote: note || null,
},
update: {
emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null,
emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null,
emailNote: note || null,
},
}); });
return { ok: true }; return { ok: true };
@@ -77,6 +90,14 @@ export default function SettingsPage() {
const [subject, setSubject] = useState(data.subject); const [subject, setSubject] = useState(data.subject);
const [intro, setIntro] = useState(data.intro); const [intro, setIntro] = useState(data.intro);
const [note, setNote] = useState(data.note); const [note, setNote] = useState(data.note);
const [notifyEnabled, setNotifyEnabled] = useState(data.notifyEnabled);
const [notifyEmail, setNotifyEmail] = useState(data.notifyEmail);
const [tagEnabled, setTagEnabled] = useState(data.tagEnabled);
const [enforceWindow, setEnforceWindow] = useState(data.enforceWindow);
const [windowDays, setWindowDays] = useState(String(data.windowDays));
const [enforceExclusions, setEnforceExclusions] = useState(
data.enforceExclusions,
);
const [showSaved, setShowSaved] = useState(false); const [showSaved, setShowSaved] = useState(false);
const saving = nav.state === "submitting"; const saving = nav.state === "submitting";
@@ -100,6 +121,12 @@ export default function SettingsPage() {
fd.set("subject", subject); fd.set("subject", subject);
fd.set("intro", intro); fd.set("intro", intro);
fd.set("note", note); fd.set("note", note);
fd.set("notifyEnabled", String(notifyEnabled));
fd.set("notifyEmail", notifyEmail);
fd.set("tagEnabled", String(tagEnabled));
fd.set("enforceWindow", String(enforceWindow));
fd.set("windowDays", windowDays);
fd.set("enforceExclusions", String(enforceExclusions));
submit(fd, { method: "post" }); submit(fd, { method: "post" });
}; };
@@ -173,6 +200,81 @@ export default function SettingsPage() {
</BlockStack> </BlockStack>
</Card> </Card>
<Card>
<BlockStack gap="400">
<Text as="h2" variant="headingMd">
Notifiche al merchant
</Text>
<Checkbox
label="Invia email di notifica a ogni recesso"
checked={notifyEnabled}
onChange={setNotifyEnabled}
/>
<TextField
label="Email notifiche"
type="email"
value={notifyEmail}
onChange={setNotifyEmail}
autoComplete="off"
disabled={!notifyEnabled}
helpText="Dove ricevere le notifiche. Senza indirizzo l'email non parte."
placeholder="ordini@tuonegozio.it"
/>
<Checkbox
label="Aggiungi il tag 'Recesso' all'ordine"
checked={tagEnabled}
onChange={setTagEnabled}
helpText="Rende l'ordine filtrabile nella lista ordini."
/>
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
</div>
</BlockStack>
</Card>
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Regole di recesso
</Text>
<Text as="p" tone="subdued">
Attiva questi controlli solo dopo aver verificato i dati. Da
spenti, il recesso è sempre accettato.
</Text>
</BlockStack>
<Checkbox
label="Blocca i recessi oltre il termine"
checked={enforceWindow}
onChange={setEnforceWindow}
helpText="Calcolato dalla data di evasione (o d'ordine) + i giorni sotto."
/>
<TextField
label="Giorni di recesso"
type="number"
value={windowDays}
onChange={setWindowDays}
autoComplete="off"
min={1}
max={365}
disabled={!enforceWindow}
/>
<Checkbox
label="Blocca i prodotti esclusi (Art. 59)"
checked={enforceExclusions}
onChange={setEnforceExclusions}
helpText="Usa le regole nella pagina Esclusioni. Blocca se l'intero ordine è escluso."
/>
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
</div>
</BlockStack>
</Card>
<Card> <Card>
<BlockStack gap="200"> <BlockStack gap="200">
<Text as="h2" variant="headingMd"> <Text as="h2" variant="headingMd">

View File

@@ -24,7 +24,9 @@ export default function App() {
<Link to="/app" rel="home"> <Link to="/app" rel="home">
Home Home
</Link> </Link>
<Link to="/app/withdrawals">Recessi</Link>
<Link to="/app/settings">Impostazioni</Link> <Link to="/app/settings">Impostazioni</Link>
<Link to="/app/exclusions">Esclusioni</Link>
</NavMenu> </NavMenu>
<Outlet /> <Outlet />
</AppProvider> </AppProvider>

View File

@@ -0,0 +1,101 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import {
Page,
Layout,
Card,
DataTable,
Text,
Badge,
BlockStack,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { formatTransmittedAt } from "../lib/recesso.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const items = await db.withdrawalRequest.findMany({
where: { shop: session.shop },
orderBy: { createdAt: "desc" },
take: 100,
});
return {
rows: items.map((w) => ({
id: w.id,
orderName: w.orderName ?? w.orderId,
customerName: w.customerName,
email: w.email,
transmittedAt: formatTransmittedAt(w.transmittedAt),
receiptSent: !!w.receiptSentAt,
hasReturn: !!w.shopifyReturnId,
})),
};
};
export default function WithdrawalsPage() {
const { rows } = useLoaderData<typeof loader>();
const tableRows = rows.map((r) => [
r.orderName,
r.customerName,
r.email,
r.transmittedAt,
r.receiptSent ? "Inviata" : "-",
r.hasReturn ? "Sì" : "-",
]);
return (
<Page>
<TitleBar title="Recessi" />
<Layout>
<Layout.Section>
<Card>
{rows.length === 0 ? (
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
Nessun recesso
</Text>
<Text as="p" tone="subdued">
Le richieste di recesso trasmesse dai clienti compariranno qui,
con la data e ora di trasmissione (registro ai fini di prova).
</Text>
</BlockStack>
) : (
<BlockStack gap="300">
<Text as="h2" variant="headingMd">
Registro recessi ({rows.length})
</Text>
<DataTable
columnContentTypes={[
"text",
"text",
"text",
"text",
"text",
"text",
]}
headings={[
"Ordine",
"Cliente",
"Email",
"Trasmesso",
"Ricevuta",
"Reso",
]}
rows={tableRows}
/>
<Badge tone="info">
Il timestamp di trasmissione attesta il momento del recesso
(art. 54-bis).
</Badge>
</BlockStack>
)}
</Card>
</Layout.Section>
</Layout>
</Page>
);
}

View File

@@ -17,16 +17,29 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import db from "../db.server"; import db from "../db.server";
import { sendWithdrawalReceipt } from "../lib/mailer.server"; import {
import { ERROR, statementTemplate, successMessage } from "../lib/recesso.copy"; sendMerchantNotification,
sendWithdrawalReceipt,
} from "../lib/mailer.server";
import {
ERROR,
EXCLUSION_REASON,
exclusionMessage,
statementTemplate,
successMessage,
} from "../lib/recesso.copy";
import { import {
MVP_LOCALE, MVP_LOCALE,
checkExclusions,
checkRateLimit, checkRateLimit,
clientIp, clientIp,
createShopifyReturn,
formatTransmittedAt, formatTransmittedAt,
getShopInfo, getShopInfo,
tagOrderRecesso,
htmlResponse, htmlResponse,
isValidEmail, isValidEmail,
isWindowExpired,
lookupOrder, lookupOrder,
renderStep1, renderStep1,
renderStep2, renderStep2,
@@ -34,6 +47,53 @@ import {
renderStep4, renderStep4,
sha256, sha256,
} from "../lib/recesso.server"; } from "../lib/recesso.server";
import type { MatchedOrder } from "../lib/recesso.server";
// A6: verifica finestra + esclusioni (rispetta i toggle nei Settings). Ritorna
// il messaggio d'errore se il recesso va bloccato, altrimenti null.
async function checkCompliance(
shop: string,
match: MatchedOrder,
): Promise<string | null> {
const settings = await db.settings
.findUnique({ where: { shop } })
.catch(() => null);
if (!settings) return null;
if (settings.enforceWindow) {
const windowDays = settings.defaultWindowDays ?? 14;
if (isWindowExpired(match, windowDays, new Date())) {
await db.auditLog.create({
data: {
shop,
event: "withdrawal_window_closed",
detail: match.orderName,
},
});
return ERROR.windowClosed;
}
}
if (settings.enforceExclusions) {
const rules = await db.exclusionRule.findMany({ where: { shop } });
const excl = checkExclusions(match, rules);
if (excl.fullyExcluded) {
await db.auditLog.create({
data: { shop, event: "withdrawal_excluded", detail: match.orderName },
});
const reasonText = excl.reasons
.map(
(r) =>
(EXCLUSION_REASON as Record<string, string>)[r] ??
"prodotto escluso dal diritto di recesso",
)
.join("; ");
return exclusionMessage(reasonText);
}
}
return null;
}
// GET /apps/recesso -> Step 1 (form di lookup). // GET /apps/recesso -> Step 1 (form di lookup).
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -123,9 +183,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
); );
} }
// TODO(A6): qui andrà il check finestra 14 gg (deadline engine). Se la // A6: finestra 14gg + esclusioni Art. 59 (se abilitate nei Settings).
// finestra è scaduta -> mostrare ERROR.windowClosed e bloccare. const block = await checkCompliance(shop, match);
// TODO(A6): qui andrà il check esclusioni Art. 59 per gli item dell'ordine. if (block) {
return htmlResponse(
renderStep1({
error: block,
orderName: orderNameInput,
email: emailInput,
}),
);
}
return htmlResponse( return htmlResponse(
renderStep2({ renderStep2({
@@ -235,7 +303,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch })); return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch }));
} }
// TODO(A6): re-check finestra 14 gg + esclusioni Art. 59 prima di registrare. // A6: re-check finestra + esclusioni (anti-tamper) prima di registrare.
const block = await checkCompliance(shop, match);
if (block) {
return htmlResponse(renderStep1({ error: block }));
}
// transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"), // transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"),
// NON di ricezione. Salvato in UTC (Prisma DateTime). // NON di ricezione. Salvato in UTC (Prisma DateTime).
@@ -328,6 +400,88 @@ export const action = async ({ request }: ActionFunctionArgs) => {
console.error("[recesso] aggiornamento stato ricevuta fallito:", e); console.error("[recesso] aggiornamento stato ricevuta fallito:", e);
} }
// Integrazione Resi Shopify: crea un reso nativo per gli ordini evasi
// (best-effort; il recesso legale e' gia' registrato). Non evaso -> il
// merchant gestisce annullo/rimborso.
let returnStatus: "created" | "no_returnable" | "error" = "error";
try {
const ret = await createShopifyReturn(admin, match.orderId);
returnStatus = ret.status;
if (ret.status === "created") {
await db.withdrawalRequest.update({
where: { id: created.id },
data: { shopifyReturnId: ret.returnId },
});
await db.auditLog.create({
data: {
shop,
event: "shopify_return_created",
detail: match.orderName,
},
});
} 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);
}
// Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders.
if (settings?.tagEnabled) {
try {
const t = await tagOrderRecesso(admin, match.orderId);
await db.auditLog.create({
data: {
shop,
event: t.ok ? "order_tagged" : "order_tag_failed",
detail: t.ok ? match.orderName : (t.error ?? "").slice(0, 200),
},
});
} catch (e) {
console.error("[recesso] tag ordine fallito:", e);
}
}
// Notifica email al merchant (se abilitata e con indirizzo impostato).
const notifyTo = settings?.notifyEmail?.trim();
if (settings?.notifyEnabled && notifyTo) {
try {
const notif = await sendMerchantNotification({
to: notifyTo,
orderName: match.orderName,
customerName,
customerEmail: email,
orderUrl: match.orderUrl,
transmittedAt: transmittedLabel,
returnStatus,
});
await db.auditLog.create({
data: {
shop,
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
detail: notif.ok ? match.orderName : notif.error.slice(0, 200),
},
});
} catch (e) {
console.error("[recesso] notifica merchant fallita:", e);
}
}
const msg = successMessage(match.orderName, transmittedLabel, email); const msg = successMessage(match.orderName, transmittedLabel, email);
return htmlResponse(renderStep4(msg)); return htmlResponse(renderStep4(msg));
} }

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "WithdrawalRequest" ADD COLUMN "shopifyReturnId" TEXT;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "notifyEmail" TEXT,
ADD COLUMN "notifyEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "tagEnabled" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "enforceExclusions" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "enforceWindow" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -52,6 +52,11 @@ model Settings {
emailSubject String? emailSubject String?
emailIntro String? emailIntro String?
emailNote String? emailNote String?
notifyEnabled Boolean @default(true)
notifyEmail String?
tagEnabled Boolean @default(true)
enforceWindow Boolean @default(false)
enforceExclusions Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -87,6 +92,7 @@ model WithdrawalRequest {
status WithdrawalStatus @default(RECEIVED) status WithdrawalStatus @default(RECEIVED)
receiptSentAt DateTime? receiptSentAt DateTime?
computedDeadline DateTime? computedDeadline DateTime?
shopifyReturnId String? // GID del Reso Shopify creato (se ordine evaso)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -43,7 +43,7 @@ api_version = "2026-04"
[access_scopes] [access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes # Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_orders,read_products" scopes = "read_orders,read_products,write_returns,write_orders"
optional_scopes = [ ] optional_scopes = [ ]
use_legacy_install_flow = false use_legacy_install_flow = false