diff --git a/app/app/lib/recesso.copy.ts b/app/app/lib/recesso.copy.ts index e768da2..cfa948d 100644 --- a/app/app/lib/recesso.copy.ts +++ b/app/app/lib/recesso.copy.ts @@ -69,6 +69,26 @@ export function successMessage( }; } +/** + * Schermata per un recesso GIA' esercitato su quest'ordine. Il diritto si + * esercita una volta sola: non registriamo un secondo atto, ma confermiamo il + * primo (con il suo timestamp legale) invece di far finta di nulla. + */ +export function duplicateMessage( + orderName: string, + transmittedAt: string, + email: string, + receiptResent: boolean, +): { line1: string; line2: string; line3: string } { + return { + line1: "Recesso già registrato", + line2: `Risulta trasmesso per l'ordine ${orderName} il ${transmittedAt}. Non serve inviarlo di nuovo.`, + line3: receiptResent + ? `Ti abbiamo re-inviato la ricevuta a ${email}.` + : `La ricevuta è stata inviata a ${email}. Controlla anche la posta indesiderata.`, + }; +} + // Template email ricevuta su supporto durevole (usato da A4). export function receiptEmailSubject(orderName: string): string { return `Ricevuta della tua richiesta di recesso - Ordine ${orderName}`; diff --git a/app/app/routes/proxy.tsx b/app/app/routes/proxy.tsx index 9fa27fb..555f37c 100644 --- a/app/app/routes/proxy.tsx +++ b/app/app/routes/proxy.tsx @@ -20,6 +20,7 @@ import db from "../db.server"; import { sendMerchantNotification, sendWithdrawalReceipt, + type ReceiptResult, type SmtpConfig, } from "../lib/mailer.server"; import { decryptSecret } from "../lib/crypto.server"; @@ -27,6 +28,7 @@ import { ERROR, EXCLUSION_REASON, NOTICE, + duplicateMessage, exclusionMessage, statementTemplate, successMessage, @@ -311,6 +313,20 @@ export const action = async ({ request }: ActionFunctionArgs) => { return htmlResponse(renderStep1({ error: ERROR.generic })); } + // Rate-limit ANCHE sulla conferma. Senza, l'endpoint e' un amplificatore: + // ogni POST manda una ricevuta al cliente + una notifica al merchant e + // brucia quota Admin API. + if (!checkRateLimit(shop, clientIp(request))) { + await db.auditLog.create({ + data: { + shop, + event: "withdrawal_confirm_rate_limited", + detail: orderName, + }, + }); + 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); @@ -324,49 +340,71 @@ export const action = async ({ request }: ActionFunctionArgs) => { return htmlResponse(renderStep1({ error: block })); } - // transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"), - // NON di ricezione. Salvato in UTC (Prisma DateTime). - const transmittedAt = new Date(); + // IDEMPOTENZA: il diritto di recesso si esercita UNA volta per contratto. + // Se esiste gia' una richiesta per quest'ordine non ne creiamo una seconda: + // il timestamp legale di trasmissione (onere della prova) deve restare uno. + // Non blocchiamo il flusso (niente dark pattern): confermiamo il primo atto. + const existing = await db.withdrawalRequest + .findFirst({ + where: { shop, orderId: match.orderId, status: { not: "REJECTED" } }, + orderBy: { transmittedAt: "asc" }, + }) + .catch(() => null); + const isDuplicate = !!existing; - 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, + let record = existing; + if (!record) { + // transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"), + // NON di ricezione. Salvato in UTC (Prisma DateTime). + const transmittedAt = new Date(); + try { + record = await db.withdrawalRequest.create({ + data: { + shop, // sempre da session.shop + orderId: match.orderId, // GID risolto dal lookup orderName: match.orderName, customerName, email, statementText, - transmittedAt: transmittedAt.toISOString(), - }), + 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 })); + } + } else { + await db.auditLog.create({ + data: { + shop, + event: "withdrawal_duplicate", detail: match.orderName, }, }); - } catch { - return htmlResponse(renderStep1({ error: ERROR.generic })); } - const transmittedLabel = formatTransmittedAt(transmittedAt); + // Sul duplicato mostriamo il timestamp ORIGINALE, non quello del click. + const transmittedLabel = formatTransmittedAt(record.transmittedAt); // A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ // persistito e valido: un invio email fallito NON deve invalidarlo. @@ -404,33 +442,64 @@ export const action = async ({ request }: ActionFunctionArgs) => { 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, - }); + // Ricevuta. Sul duplicato non ri-registriamo nulla: al massimo RE-inviamo + // la ricevuta, non piu' di una volta all'ora (aiuta chi non l'ha ricevuta, + // senza trasformare l'endpoint in un amplificatore di email). + let shouldSend = true; + if (isDuplicate) { + const since = new Date(Date.now() - 60 * 60 * 1000); + const recent = await db.auditLog + .count({ + where: { + shop, + event: "receipt_resent", + detail: match.orderName, + createdAt: { gt: since }, + }, + }) + .catch(() => 1); + shouldSend = recent === 0; + } + + let receipt: ReceiptResult | null = null; + if (shouldSend) { + 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, + }); + } + + let resent = false; 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 { + if (receipt?.ok) { + if (isDuplicate) { + resent = true; + await db.auditLog.create({ + data: { shop, event: "receipt_resent", detail: match.orderName }, + }); + } else { + await db.withdrawalRequest.update({ + where: { id: record.id }, + data: { receiptSentAt: new Date() }, + }); + await db.auditLog.create({ + data: { shop, event: "receipt_sent", detail: match.orderName }, + }); + } + } else if (receipt) { console.error("[recesso] invio ricevuta fallito:", receipt.error); await db.auditLog.create({ data: { @@ -440,13 +509,21 @@ export const action = async ({ request }: ActionFunctionArgs) => { detail: redactErr(receipt.error), }, }); - // 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); } + // Duplicato: niente secondo reso, tag, notifica o annullo automatico. + // Confermiamo il primo atto (col suo timestamp) e usciamo. + if (isDuplicate) { + return htmlResponse( + renderStep4( + duplicateMessage(match.orderName, transmittedLabel, email, resent), + ), + ); + } + // 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 @@ -472,7 +549,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { returnStatus = ret.status; if (ret.status === "created") { await db.withdrawalRequest.update({ - where: { id: created.id }, + where: { id: record.id }, data: { shopifyReturnId: ret.returnId }, }); await db.auditLog.create({ @@ -586,7 +663,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { match.orderName, transmittedLabel, email, - receipt.ok, + !!receipt?.ok, ); return htmlResponse(renderStep4(msg)); }