- 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
587 lines
19 KiB
TypeScript
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());
|
|
}
|
|
};
|