diff --git a/TEST-CHECKLIST.md b/TEST-CHECKLIST.md new file mode 100644 index 0000000..89d006d --- /dev/null +++ b/TEST-CHECKLIST.md @@ -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. diff --git a/app/app/lib/mailer.server.ts b/app/app/lib/mailer.server.ts index 4b07237..1e6d13f 100644 --- a/app/app/lib/mailer.server.ts +++ b/app/app/lib/mailer.server.ts @@ -91,3 +91,87 @@ export async function sendWithdrawalReceipt(params: { }; } } + +function escM(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * 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 { + 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) + ? `

Apri l'ordine

` + : ""; + + const subject = `Nuovo recesso - Ordine ${params.orderName}`; + const html = ` + +
+ +
+

Nuova richiesta di recesso

+

Un cliente ha esercitato il diritto di recesso.

+
+
Ordine: ${escM(params.orderName)}
+
Cliente: ${escM(params.customerName)}
+
Email: ${escM(params.customerEmail)}
+
Trasmesso: ${escM(params.transmittedAt)}
+
+

${actionLine}

+${orderBtn} +
+
+`; + 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", + }; + } +} diff --git a/app/app/lib/recesso.copy.ts b/app/app/lib/recesso.copy.ts index 4e7cdf9..c8b0908 100644 --- a/app/app/lib/recesso.copy.ts +++ b/app/app/lib/recesso.copy.ts @@ -101,6 +101,7 @@ export const EXCLUSION_REASON = { PERISHABLE: "prodotto deperibile o a rapida scadenza", HYGIENE: "prodotto sigillato, aperto dopo la consegna, non restituibile per motivi igienici o di salute", + OTHER: "prodotto escluso dal diritto di recesso", } as const; export function exclusionMessage(motivoEsclusione: string): string { diff --git a/app/app/lib/recesso.server.ts b/app/app/lib/recesso.server.ts index 8acd703..83c4ead 100644 --- a/app/app/lib/recesso.server.ts +++ b/app/app/lib/recesso.server.ts @@ -15,6 +15,7 @@ import { createHash } from "node:crypto"; 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"; // --------------------------------------------------------------------------- @@ -122,6 +123,81 @@ export interface MatchedOrder { email: string; // email dell'ordine (per precompilazione) createdAt: string; 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(); + 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 { @@ -134,6 +210,16 @@ interface OrderLookupGraphQL { email?: string | null; createdAt?: 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; @@ -150,6 +236,21 @@ const ORDER_LOOKUP_QUERY = `#graphql email createdAt 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 { } } +// --- 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 { + 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( admin: AdminApiContext, orderInput: string, @@ -240,12 +499,27 @@ export async function lookupOrder( node.name.toLowerCase() === normalized.toLowerCase(); const emailMatch = (node.email ?? "").trim().toLowerCase() === email; 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 => !!n) + .map((n) => ({ + productId: n.product?.id ?? null, + tags: n.product?.tags ?? [], + })); return { orderId: node.id, orderName: node.name, email: node.email ?? emailInput.trim(), createdAt: node.createdAt ?? "", orderUrl: node.statusPageUrl ?? "", + fulfilledAt: fulfillmentDates.length + ? fulfillmentDates[fulfillmentDates.length - 1] + : null, + lineItems, }; } } diff --git a/app/app/routes/app.exclusions.tsx b/app/app/routes/app.exclusions.tsx new file mode 100644 index 0000000..be655e8 --- /dev/null +++ b/app/app/routes/app.exclusions.tsx @@ -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 = { + TAG: "Tag", + PRODUCT: "Prodotto", + ALL: "Tutto l'ordine", + COLLECTION: "Collezione", +}; +const REASON_LABEL: Record = { + 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(); + const actionData = useActionData(); + 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 ( + + + + + + {actionData && actionData.ok === false && actionData.error ? ( + {actionData.error} + ) : null} + + + + + + Aggiungi esclusione + + + 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. + + + + + + + + + + + + + Regole attive ({rules.length}) + + {rules.length === 0 ? ( + + Nessuna regola. + + ) : ( + + {rules.map((r) => ( + + + {SCOPE_LABEL[r.scope] ?? r.scope} + {r.targetId ? ( + {r.targetId} + ) : null} + + {REASON_LABEL[r.reason] ?? r.reason} + + + + + ))} + + )} + + + + + + + ); +} diff --git a/app/app/routes/app.settings.tsx b/app/app/routes/app.settings.tsx index 16aa286..c97f1aa 100644 --- a/app/app/routes/app.settings.tsx +++ b/app/app/routes/app.settings.tsx @@ -18,6 +18,7 @@ import { BlockStack, InlineStack, Badge, + Checkbox, } from "@shopify/polaris"; import { TitleBar } from "@shopify/app-bridge-react"; @@ -40,6 +41,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { subject: settings?.emailSubject ?? DEFAULT_SUBJECT, intro: settings?.emailIntro ?? DEFAULT_INTRO, 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 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({ where: { shop: session.shop }, - create: { - shop: session.shop, - 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, - }, + create: { shop: session.shop, ...data }, + update: data, }); return { ok: true }; @@ -77,6 +90,14 @@ export default function SettingsPage() { const [subject, setSubject] = useState(data.subject); const [intro, setIntro] = useState(data.intro); 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 saving = nav.state === "submitting"; @@ -100,6 +121,12 @@ export default function SettingsPage() { fd.set("subject", subject); fd.set("intro", intro); 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" }); }; @@ -173,6 +200,81 @@ export default function SettingsPage() { + + + + Notifiche al merchant + + + + +
+ +
+
+
+ + + + + + Regole di recesso + + + Attiva questi controlli solo dopo aver verificato i dati. Da + spenti, il recesso è sempre accettato. + + + + + +
+ +
+
+
+ diff --git a/app/app/routes/app.tsx b/app/app/routes/app.tsx index bcc6a06..b8a4076 100644 --- a/app/app/routes/app.tsx +++ b/app/app/routes/app.tsx @@ -24,7 +24,9 @@ export default function App() { Home + Recessi Impostazioni + Esclusioni diff --git a/app/app/routes/app.withdrawals.tsx b/app/app/routes/app.withdrawals.tsx new file mode 100644 index 0000000..1b3ccfd --- /dev/null +++ b/app/app/routes/app.withdrawals.tsx @@ -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(); + + const tableRows = rows.map((r) => [ + r.orderName, + r.customerName, + r.email, + r.transmittedAt, + r.receiptSent ? "Inviata" : "-", + r.hasReturn ? "Sì" : "-", + ]); + + return ( + + + + + + {rows.length === 0 ? ( + + + Nessun recesso + + + Le richieste di recesso trasmesse dai clienti compariranno qui, + con la data e ora di trasmissione (registro ai fini di prova). + + + ) : ( + + + Registro recessi ({rows.length}) + + + + Il timestamp di trasmissione attesta il momento del recesso + (art. 54-bis). + + + )} + + + + + ); +} diff --git a/app/app/routes/proxy.tsx b/app/app/routes/proxy.tsx index 685c1c9..59f61dd 100644 --- a/app/app/routes/proxy.tsx +++ b/app/app/routes/proxy.tsx @@ -17,16 +17,29 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; import db from "../db.server"; -import { sendWithdrawalReceipt } from "../lib/mailer.server"; -import { ERROR, statementTemplate, successMessage } from "../lib/recesso.copy"; +import { + sendMerchantNotification, + sendWithdrawalReceipt, +} from "../lib/mailer.server"; +import { + ERROR, + EXCLUSION_REASON, + exclusionMessage, + statementTemplate, + successMessage, +} from "../lib/recesso.copy"; import { MVP_LOCALE, + checkExclusions, checkRateLimit, clientIp, + createShopifyReturn, formatTransmittedAt, getShopInfo, + tagOrderRecesso, htmlResponse, isValidEmail, + isWindowExpired, lookupOrder, renderStep1, renderStep2, @@ -34,6 +47,53 @@ import { renderStep4, sha256, } 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 { + 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)[r] ?? + "prodotto escluso dal diritto di recesso", + ) + .join("; "); + return exclusionMessage(reasonText); + } + } + + return null; +} // GET /apps/recesso -> Step 1 (form di lookup). 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 - // finestra è scaduta -> mostrare ERROR.windowClosed e bloccare. - // TODO(A6): qui andrà il check esclusioni Art. 59 per gli item dell'ordine. + // A6: finestra 14gg + esclusioni Art. 59 (se abilitate nei Settings). + const block = await checkCompliance(shop, match); + if (block) { + return htmlResponse( + renderStep1({ + error: block, + orderName: orderNameInput, + email: emailInput, + }), + ); + } return htmlResponse( renderStep2({ @@ -235,7 +303,11 @@ export const action = async ({ request }: ActionFunctionArgs) => { 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"), // 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); } + // 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); return htmlResponse(renderStep4(msg)); } diff --git a/app/prisma/migrations/20260707103200_shopify_return_id/migration.sql b/app/prisma/migrations/20260707103200_shopify_return_id/migration.sql new file mode 100644 index 0000000..8eb8982 --- /dev/null +++ b/app/prisma/migrations/20260707103200_shopify_return_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "WithdrawalRequest" ADD COLUMN "shopifyReturnId" TEXT; diff --git a/app/prisma/migrations/20260707110011_merchant_notify_tag/migration.sql b/app/prisma/migrations/20260707110011_merchant_notify_tag/migration.sql new file mode 100644 index 0000000..a692167 --- /dev/null +++ b/app/prisma/migrations/20260707110011_merchant_notify_tag/migration.sql @@ -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; diff --git a/app/prisma/migrations/20260707110800_enforce_toggles/migration.sql b/app/prisma/migrations/20260707110800_enforce_toggles/migration.sql new file mode 100644 index 0000000..52328d4 --- /dev/null +++ b/app/prisma/migrations/20260707110800_enforce_toggles/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Settings" ADD COLUMN "enforceExclusions" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "enforceWindow" BOOLEAN NOT NULL DEFAULT false; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index a2b8f20..62ff036 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -52,6 +52,11 @@ model Settings { emailSubject String? emailIntro 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()) updatedAt DateTime @updatedAt @@ -87,6 +92,7 @@ model WithdrawalRequest { status WithdrawalStatus @default(RECEIVED) receiptSentAt DateTime? computedDeadline DateTime? + shopifyReturnId String? // GID del Reso Shopify creato (se ordine evaso) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/app/shopify.app.toml b/app/shopify.app.toml index 44a07a4..2dfe065 100644 --- a/app/shopify.app.toml +++ b/app/shopify.app.toml @@ -43,7 +43,7 @@ api_version = "2026-04" [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 = [ ] use_legacy_install_flow = false