/** * Helper server-only per il flusso di recesso guest (App Proxy). * * Responsabilità: * - lookup ordine via Admin GraphQL (anti-leak: nessuna differenza tra ordine * inesistente ed email non combaciante); * - rate-limit base per shop+IP (hardening -> A9); * - hashing payload per l'audit trail; * - formattazione timestamp di TRASMISSIONE (Europe/Rome). * * Il RENDERING vive in ./recesso.view.ts (modulo puro, condiviso con l'anteprima * admin). Qui lo ri-esportiamo, cosi' i chiamanti non cambiano. * * NB: `import "server-only"` non è disponibile qui; il suffisso `.server.ts` * garantisce che Remix non impacchetti questo modulo nel bundle client. */ import { createHash } from "node:crypto"; import type { AdminApiContext } from "@shopify/shopify-app-remix/server"; import type { ExclusionRule } from "@prisma/client"; export { renderStep1, renderStep2, renderStep3, renderStep4, } from "./recesso.view"; // Locale MVP fisso (multi-lingua -> A7). export const MVP_LOCALE = "it"; // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function isValidEmail(value: string): boolean { return EMAIL_RE.test(value.trim()); } /** Rimuove il prefisso '#' e spazi dal numero ordine digitato dall'utente. */ export function normalizeOrderName(input: string): string { return input.trim().replace(/^#+/, "").trim(); } export function sha256(payload: unknown): string { return createHash("sha256") .update(typeof payload === "string" ? payload : JSON.stringify(payload)) .digest("hex"); } /** * Formatta l'istante di TRASMISSIONE in modo leggibile e conservabile, con fuso * orario esplicito, es. "06/07/2026, 14:32:07 CEST". Il DB conserva UTC. */ export function formatTransmittedAt(date: Date): string { return new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: "short", }).format(date); } /** Estrae l'IP client dell'utente storefront (App Proxy forwarda X-Forwarded-For). */ export function clientIp(request: Request): string { const xff = request.headers.get("X-Forwarded-For"); if (xff) return xff.split(",")[0]!.trim(); return request.headers.get("X-Real-IP")?.trim() || "unknown"; } // --------------------------------------------------------------------------- // Rate-limit base (in-memory, single-instance) - hardening -> A9. // Finestra fissa per chiave shop+IP. Predisposto per la verifica avversariale A9. // TODO(A9): sostituire con store condiviso (Redis/DB) per il deploy multi-istanza, // aggiungere backoff/captcha oltre soglia e risposte a tempo costante // (attualmente evitiamo solo leak di testo/status, non di timing). // --------------------------------------------------------------------------- const RATE_WINDOW_MS = 15 * 60 * 1000; // 15 minuti const RATE_MAX_ATTEMPTS = 8; // tentativi di lookup per finestra, per shop+IP const rateBucket = new Map(); let lastRatePruneAt = 0; /** Rimuove le voci scadute dal bucket (evita crescita illimitata della Map). */ function pruneRateBucket(now: number): void { if (now - lastRatePruneAt < 60_000) return; lastRatePruneAt = now; for (const [k, v] of rateBucket) { if (v.resetAt <= now) rateBucket.delete(k); } } /** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */ export function checkRateLimit(shop: string, ip: string): boolean { const key = `${shop}:${ip}`; const now = Date.now(); pruneRateBucket(now); const entry = rateBucket.get(key); if (!entry || entry.resetAt <= now) { rateBucket.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS }); return true; } if (entry.count >= RATE_MAX_ATTEMPTS) { return false; } entry.count += 1; return true; } // --------------------------------------------------------------------------- // Lookup ordine via Admin GraphQL. // --------------------------------------------------------------------------- export interface MatchedOrder { orderId: string; // GID Shopify (gid://shopify/Order/...) orderName: string; // es. "#1001" 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 (spedizione), null se non evaso deliveredAt: string | null; // data di consegna (possesso fisico, Art. 52), null se non consegnato cancelledAt: string | null; // data annullamento ordine, null altrimenti financialStatus: string | null; // displayFinancialStatus (REFUNDED/VOIDED/PAID/...) lineItems: Array<{ productId: string | null; tags: string[] }>; } // --- A6: finestra di recesso (deadline engine) --------------------------- // Riferimento = data di CONSEGNA (possesso fisico, Art. 52), NON la spedizione. // Se non consegnato -> la finestra non e' iniziata -> nessuna scadenza (non si // blocca mai): il recesso resta ammesso (nasce dalla conclusione del contratto). // 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 { if (!match.deliveredAt) return null; const d = new Date(match.deliveredAt); 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], }; } // --- 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?: { edges?: Array<{ node?: { id?: string | null; name?: string | null; email?: string | null; createdAt?: string | null; statusPageUrl?: string | null; cancelledAt?: string | null; displayFulfillmentStatus?: string | null; displayFinancialStatus?: string | null; fulfillments?: Array<{ createdAt?: string | null; events?: { edges?: Array<{ node?: { status?: string | null; happenedAt?: string | null; } | null; } | null> | null; } | 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; } const ORDER_LOOKUP_QUERY = `#graphql query recessoOrderLookup($query: String!) { orders(first: 5, query: $query) { edges { node { id name email createdAt statusPageUrl cancelledAt displayFulfillmentStatus displayFinancialStatus fulfillments(first: 10) { createdAt events(first: 25) { edges { node { status happenedAt } } } } lineItems(first: 50) { edges { node { quantity product { id tags } } } } } } } }`; /** * Cerca l'ordine per numero e verifica che l'email combaci. * * ANTI-LEAK (SPEC §6 / R2): sia se l'ordine non esiste sia se l'email non * corrisponde, la funzione ritorna `null` in modo indistinguibile. Il chiamante * mostra SEMPRE lo stesso messaggio (ERROR.lookupNoMatch) con lo stesso status. * * NB "Protected Customer Data": in PRODUZIONE la lettura di `order.email` via * Admin API richiede l'approvazione Shopify "Protected customer data access". * Su dev/custom store funziona senza. // TODO: richiedere l'accesso pre-go-live. * * Multi-tenant: `admin` proviene dalla sessione App Proxy, quindi la query è * già scoped allo shop corretto; non ci fidiamo mai dello shop lato client. */ export interface ShopInfo { name: string; contactEmail: string; url: string; } interface ShopInfoGraphQL { data?: { shop?: { name?: string | null; contactEmail?: string | null; primaryDomain?: { url?: string | null } | null; } | null; } | null; } const SHOP_INFO_QUERY = `#graphql query recessoShopInfo { shop { name primaryDomain { url } } }`; /** Dati del negozio per l'email (nome, URL, email contatto). Fallback vuoto su errore. */ export async function getShopInfo(admin: AdminApiContext): Promise { try { const res = await admin.graphql(SHOP_INFO_QUERY); const body = (await res.json()) as ShopInfoGraphQL; const s = body.data?.shop; return { name: s?.name?.trim() ?? "", contactEmail: "", url: s?.primaryDomain?.url?.trim() ?? "", }; } catch { return { name: "", contactEmail: "", url: "" }; } } // --- Integrazione Resi Shopify ------------------------------------------- const RETURNABLE_QUERY = `#graphql query recessoOrderFulfillments($orderId: ID!) { order(id: $orderId) { returns(first: 1) { edges { node { id } } } 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?: { returns?: { edges?: Array | null } | null; 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: "exists" } // esiste gia' un reso per l'ordine | { 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; // Se esiste gia' un reso per l'ordine, non crearne un altro (evita errore fuorviante). if ((qBody.data?.order?.returns?.edges ?? []).length > 0) { return { status: "exists" }; } 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", }; } } 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, emailInput: string, ): Promise { const normalized = normalizeOrderName(orderInput); const email = emailInput.trim().toLowerCase(); if (!normalized || !email) return null; let body: OrderLookupGraphQL; try { const res = await admin.graphql(ORDER_LOOKUP_QUERY, { variables: { query: `name:#${normalized}` }, }); body = (await res.json()) as OrderLookupGraphQL; } catch { // Errore API: trattiamo come "nessun match" per non rivelare nulla. return null; } const edges = body.data?.orders?.edges ?? []; const wantedName = `#${normalized}`.toLowerCase(); for (const edge of edges) { const node = edge?.node; if (!node?.id || !node.name) continue; const nameMatch = node.name.toLowerCase() === wantedName || 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 deliveryDates = (node.fulfillments ?? []) .flatMap((f) => f?.events?.edges ?? []) .map((e) => e?.node) .filter((n): n is NonNullable => !!n) .filter((n) => n.status === "DELIVERED" && !!n.happenedAt) .map((n) => n.happenedAt as string) .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, deliveredAt: deliveryDates.length ? deliveryDates[deliveryDates.length - 1] : null, cancelledAt: node.cancelledAt ?? null, financialStatus: node.displayFinancialStatus ?? null, lineItems, }; } } return null; } /** Helper: Response HTML standalone (status 200 di default per non leakare via status). */ export function htmlResponse(html: string, status = 200): Response { return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" }, }); }