Files
pcrt-legal-return/app/app/routes/proxy.tsx
tommaso 3bb7a30c1d SMTP per-shop configurabile dalle Impostazioni (password cifrata AES-256-GCM)
- Settings: smtpHost/Port/User/Pass(cifrata)/Secure/From. Vuoto -> provider
  default dell'app (env); compilato -> invio dal SMTP del merchant.
- crypto.server: encrypt/decrypt AES-256-GCM (chiave APP_ENCRYPTION_KEY).
- mailer: SmtpConfig + buildTransport(smtp) per-shop o env; mailFrom override.
  Sia ricevuta cliente sia notifica merchant usano lo SMTP del merchant.
- proxy: costruisce SmtpConfig (decifra pass), passa ai due invii.
- admin: nuovo tab 'Email (SMTP)'; password mai ri-mostrata (vuoto = invariata).
- Migrazione smtp_settings. APP_ENCRYPTION_KEY: dev in .env, prod = fly secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 17:29:33 +02:00

587 lines
19 KiB
TypeScript

/**
* App Proxy — flusso di recesso guest (SPEC-MVP-RECESSO §4).
*
* Storefront `/apps/recesso` -> (App Proxy) -> questa route `/proxy`.
* Config in shopify.app.toml: [app_proxy] url=<app>/proxy, subpath=recesso, prefix=apps.
*
* La route espone SOLO loader + action e ritorna sempre una `Response` HTML
* standalone (nessun default component, nessun Polaris, nessun root layout admin):
* è il pattern delle route App Proxy (come l'helper `liquid()`).
*
* Macchina a stati a 2 step (guest, zero JS client). Lo stato viaggia tra gli
* step in campi hidden delle form; nessuno stato server è persistito prima della
* conferma finale. Ogni richiesta è autenticata da authenticate.public.appProxy,
* che verifica la firma HMAC di Shopify e fornisce session/admin scoped allo shop.
*/
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import {
sendMerchantNotification,
sendWithdrawalReceipt,
type SmtpConfig,
} from "../lib/mailer.server";
import { decryptSecret } from "../lib/crypto.server";
import {
ERROR,
EXCLUSION_REASON,
NOTICE,
exclusionMessage,
statementTemplate,
successMessage,
} from "../lib/recesso.copy";
import {
MVP_LOCALE,
checkExclusions,
checkRateLimit,
cancelOrder,
clientIp,
createShopifyReturn,
formatTransmittedAt,
getShopInfo,
tagOrderRecesso,
htmlResponse,
isValidEmail,
isWindowExpired,
lookupOrder,
orderState,
renderStep1,
renderStep2,
renderStep3,
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<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).
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.public.appProxy(request);
return htmlResponse(renderStep1());
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session, admin } = await authenticate.public.appProxy(request);
// Narrowing: senza sessione offline non abbiamo Admin API per lo shop.
// Multi-tenant: lo shop lo prendiamo SOLO da session.shop, mai dal client.
if (!session || !admin) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
}
const shop = session.shop;
const form = await request.formData();
const intent = String(form.get("intent") ?? "");
switch (intent) {
// -------------------------------------------------------------------
// STEP 1 -> lookup ordine (anti-leak) -> STEP 2
// -------------------------------------------------------------------
case "lookup": {
const orderNameInput = String(form.get("orderName") ?? "").trim();
const emailInput = String(form.get("email") ?? "").trim();
// Validazione base (stesso status 200 di ogni risposta in-flow).
if (!orderNameInput || !emailInput) {
return htmlResponse(
renderStep1({
error: ERROR.missingField,
orderName: orderNameInput,
email: emailInput,
}),
);
}
if (!isValidEmail(emailInput)) {
return htmlResponse(
renderStep1({
error: ERROR.invalidEmail,
orderName: orderNameInput,
email: emailInput,
}),
);
}
// Rate-limit base per shop+IP (hardening -> A9).
if (!checkRateLimit(shop, clientIp(request))) {
await db.auditLog.create({
data: {
shop,
event: "withdrawal_lookup_failed",
payloadHash: sha256({ orderNameInput, emailInput }),
detail: "rate_limited",
},
});
return htmlResponse(
renderStep1({
error: ERROR.generic,
orderName: orderNameInput,
email: emailInput,
}),
);
}
const match = await lookupOrder(admin, orderNameInput, emailInput);
// ANTI-LEAK (SPEC §6 / R2): ordine inesistente ED email non combaciante
// producono lo STESSO messaggio, stesso testo e stesso status 200.
if (!match) {
await db.auditLog.create({
data: {
shop,
event: "withdrawal_lookup_failed",
payloadHash: sha256({ orderNameInput, emailInput }), // no PII in chiaro
detail: "no_match",
},
});
return htmlResponse(
renderStep1({
error: ERROR.lookupNoMatch,
orderName: orderNameInput,
email: emailInput,
}),
);
}
// 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,
}),
);
}
const orderClosed =
!!match.cancelledAt ||
match.financialStatus === "REFUNDED" ||
match.financialStatus === "VOIDED";
return htmlResponse(
renderStep2({
orderId: match.orderId,
orderName: match.orderName,
email: match.email,
statementText: statementTemplate(match.orderName),
notice: orderClosed ? NOTICE.orderClosed : undefined,
}),
);
}
// -------------------------------------------------------------------
// STEP 2 -> validazione 4 dati -> STEP 3 (riepilogo)
// -------------------------------------------------------------------
case "details": {
const orderId = String(form.get("orderId") ?? "").trim();
const orderName = String(form.get("orderName") ?? "").trim();
const customerName = String(form.get("customerName") ?? "").trim();
const email = String(form.get("email") ?? "").trim();
const statementText = String(form.get("statementText") ?? "").trim();
// Sicurezza: se mancano i riferimenti d'ordine (tamper/link diretto),
// riparti dallo Step 1 senza rivelare nulla.
if (!orderId || !orderName) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
}
if (!customerName || !email || !statementText) {
return htmlResponse(
renderStep2({
orderId,
orderName,
email,
customerName,
statementText: statementText || statementTemplate(orderName),
error: ERROR.missingField,
}),
);
}
if (!isValidEmail(email)) {
return htmlResponse(
renderStep2({
orderId,
orderName,
email,
customerName,
statementText,
error: ERROR.invalidEmail,
}),
);
}
return htmlResponse(
renderStep3({ orderId, orderName, email, customerName, statementText }),
);
}
// -------------------------------------------------------------------
// STEP 3 «Torna indietro» -> ripopola STEP 2 (editing consentito, non dark pattern)
// -------------------------------------------------------------------
case "edit": {
const orderId = String(form.get("orderId") ?? "").trim();
const orderName = String(form.get("orderName") ?? "").trim();
const customerName = String(form.get("customerName") ?? "").trim();
const email = String(form.get("email") ?? "").trim();
const statementText = String(form.get("statementText") ?? "").trim();
if (!orderId || !orderName) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
}
return htmlResponse(
renderStep2({
orderId,
orderName,
email,
customerName,
statementText: statementText || statementTemplate(orderName),
}),
);
}
// -------------------------------------------------------------------
// STEP 3 «Conferma recesso» -> TRASMISSIONE: persistenza + audit -> STEP 4
// Unica azione che registra la richiesta (funzione dedicata, no checkbox).
// -------------------------------------------------------------------
case "confirm": {
const orderId = String(form.get("orderId") ?? "").trim();
const orderName = String(form.get("orderName") ?? "").trim();
const customerName = String(form.get("customerName") ?? "").trim();
const email = String(form.get("email") ?? "").trim();
const statementText = String(form.get("statementText") ?? "").trim();
if (
!orderId ||
!orderName ||
!customerName ||
!email ||
!statementText ||
!isValidEmail(email)
) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
}
// Re-verifica server-side (integrità hidden fields / anti-tamper):
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
const match = await lookupOrder(admin, orderName, email);
if (!match || match.orderId !== orderId) {
return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch }));
}
// 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).
const transmittedAt = new Date();
let created;
try {
created = await db.withdrawalRequest.create({
data: {
shop, // sempre da session.shop
orderId: match.orderId, // GID risolto dal lookup
orderName: match.orderName,
customerName,
email,
statementText,
transmittedAt,
channel: "GUEST",
locale: MVP_LOCALE,
status: "RECEIVED",
receiptSentAt: null, // valorizzato da A4 dopo invio ricevuta
},
});
// Audit trail append-only (SPEC R6): evento + hash del payload.
await db.auditLog.create({
data: {
shop,
event: "withdrawal_received",
payloadHash: sha256({
orderId: match.orderId,
orderName: match.orderName,
customerName,
email,
statementText,
transmittedAt: transmittedAt.toISOString(),
}),
detail: match.orderName,
},
});
} catch {
return htmlResponse(renderStep1({ error: ERROR.generic }));
}
const transmittedLabel = formatTransmittedAt(transmittedAt);
// A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ
// persistito e valido: un invio email fallito NON deve invalidarlo.
const [settings, shopInfo] = await Promise.all([
db.settings.findUnique({ where: { shop } }).catch(() => null),
getShopInfo(admin),
]);
const shopName = shopInfo.name || shop.replace(/\.myshopify\.com$/, "");
// A6-bis: blocco operativo per stato ordine (se stateAwareEmail attivo).
const state = orderState(match);
const operational =
settings?.stateAwareEmail !== false &&
(state === "unfulfilled" || state === "shipped" || state === "delivered")
? {
state,
textUnfulfilled: settings?.opTextUnfulfilled,
textShipped: settings?.opTextShipped,
returnAddress: settings?.returnAddress,
atCustomerExpense: settings?.returnAtCustomerExpense ?? true,
}
: null;
// SMTP per-shop (se configurato): password decifrata; altrimenti default app.
let smtp: SmtpConfig | null = null;
if (settings?.smtpHost) {
try {
smtp = {
host: settings.smtpHost,
port: settings.smtpPort,
user: settings.smtpUser,
pass: settings.smtpPass ? decryptSecret(settings.smtpPass) : null,
secure: settings.smtpSecure,
from: settings.smtpFrom,
};
} catch {
console.error("[recesso] SMTP shop non decifrabile: uso default app");
}
}
const receipt = await sendWithdrawalReceipt({
to: email,
vars: {
shopName,
shopUrl: shopInfo.url,
orderName: match.orderName,
orderUrl: match.orderUrl,
customerName,
transmittedAt: transmittedLabel,
statementText,
},
subject: settings?.emailSubject,
intro: settings?.emailIntro,
note: settings?.emailNote,
operational,
smtp,
});
try {
if (receipt.ok) {
await db.withdrawalRequest.update({
where: { id: created.id },
data: { receiptSentAt: new Date() },
});
await db.auditLog.create({
data: { shop, event: "receipt_sent", detail: match.orderName },
});
} else {
console.error("[recesso] invio ricevuta fallito:", receipt.error);
await db.auditLog.create({
data: {
shop,
event: "receipt_failed",
// no PII in audit: l'errore SMTP puo' contenere l'email.
detail: "invio ricevuta fallito",
},
});
// Retry in-request nel mailer (trySend) + messaggio di successo onesto
// (successMessage riceve receipt.ok). Coda persistente = eventuale futuro.
}
} catch (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). Ordini annullati/
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
// merchant gestisce annullo/rimborso.
let returnStatus: "created" | "no_returnable" | "exists" | "error" =
"error";
const orderClosed =
!!match.cancelledAt ||
match.financialStatus === "REFUNDED" ||
match.financialStatus === "VOIDED";
if (orderClosed) {
returnStatus = "no_returnable";
await db.auditLog.create({
data: {
shop,
event: "shopify_return_skipped",
detail: "ordine annullato o rimborsato",
},
});
} else {
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 if (ret.status === "exists") {
await db.auditLog.create({
data: {
shop,
event: "shopify_return_exists",
detail: match.orderName,
},
});
} 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);
}
}
// A6-bis: auto-annullo ordini non evasi (se abilitato). refund + restock.
if (settings?.autoCancelUnfulfilled && state === "unfulfilled") {
try {
const c = await cancelOrder(admin, match.orderId);
await db.auditLog.create({
data: {
shop,
event: c.ok ? "order_auto_cancelled" : "order_auto_cancel_failed",
detail: c.ok ? match.orderName : (c.error ?? "").slice(0, 200),
},
});
} catch (e) {
console.error("[recesso] auto-annullo fallito:", e);
}
}
// Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders.
if (settings?.tagEnabled) {
try {
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,
smtp,
});
if (!notif.ok) {
console.error("[recesso] notifica merchant fallita:", notif.error);
}
await db.auditLog.create({
data: {
shop,
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
// no PII in audit: l'errore SMTP puo' contenere l'email.
detail: notif.ok ? match.orderName : "notifica merchant fallita",
},
});
} catch (e) {
console.error("[recesso] notifica merchant fallita:", e);
}
}
const msg = successMessage(
match.orderName,
transmittedLabel,
email,
receipt.ok,
);
return htmlResponse(renderStep4(msg));
}
default:
// Intent sconosciuto: torna allo Step 1 senza rivelare dettagli.
return htmlResponse(renderStep1());
}
};