Files
pcrt-legal-return/app/app/lib/recesso.server.ts
tommaso e51828a8d0 Estrai recesso.view.ts: vista pura, condivisibile con l'admin
PAGE_CSS, renderShell, stepLayout, renderStep1..4, escapeHtml/attr e
PROXY_STOREFRONT_PATH spostati in un modulo PURO (nessun node/server).
recesso.server.ts li ri-esporta: nessun chiamante cambia (proxy invariato).

Motivo: l'anteprima nell'admin deve rendere lo stesso identico markup e CSS
dello storefront, e non puo' farlo via <iframe src=route-admin> perche' l'admin
embedded si autentica con session token via App Bridge, che una navigazione
iframe non trasporta. Serve anche per il livello 2 (eredita il tema).

Rimosso il parametro 'theme' spurio che il replace globale aveva aggiunto a
stepLayout (non lo usa: il tema lo applica renderShell).
2026-07-10 14:14:24 +02:00

644 lines
20 KiB
TypeScript

/**
* 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<string, { count: number; resetAt: number }>();
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<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],
};
}
// --- 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<ShopInfo> {
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<unknown> | 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<ReturnCreation> {
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<MatchedOrder | null> {
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<typeof n> => !!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<typeof n> => !!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" },
});
}