Idempotenza del recesso + rate-limit sulla conferma

Due difetti sullo stesso endpoint:

1) Nessun controllo duplicati: ogni POST 'confirm' creava una nuova
   WithdrawalRequest. Il diritto di recesso si esercita UNA volta per contratto:
   una seconda dichiarazione e' senza oggetto e, peggio, inquina l'onere della
   prova (N timestamp di trasmissione per un atto che deve averne uno).
   Ora: se esiste gia' una richiesta per (shop, orderId) non REJECTED, non se ne
   crea una seconda; niente secondo reso, tag, notifica o annullo automatico.
   Audit 'withdrawal_duplicate'. La schermata conferma il primo atto col suo
   timestamp originale (nessun blocco del flusso: non e' un dark pattern).
   Ricevuta re-inviabile al massimo 1 volta/ora (aiuta chi non l'ha ricevuta
   senza amplificare), tracciata da audit 'receipt_resent'; il timestamp legale
   e receiptSentAt originali non vengono toccati.

2) checkRateLimit era applicato SOLO al case 'lookup'. La conferma era libera:
   ogni POST inviava una ricevuta al cliente + una notifica al merchant e
   bruciava quota Admin API -> amplificatore di email raggiungibile da chiunque
   navighi lo store. Ora e' limitata, con audit 'withdrawal_confirm_rate_limited'.
This commit is contained in:
2026-07-10 10:31:47 +02:00
parent 4998795648
commit fa8ee9c93f
2 changed files with 160 additions and 63 deletions

View File

@@ -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}`;

View File

@@ -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));
}