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:
@@ -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}`;
|
||||
|
||||
@@ -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,13 +340,25 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
return htmlResponse(renderStep1({ error: block }));
|
||||
}
|
||||
|
||||
// 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 record = existing;
|
||||
if (!record) {
|
||||
// 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({
|
||||
record = await db.withdrawalRequest.create({
|
||||
data: {
|
||||
shop, // sempre da session.shop
|
||||
orderId: match.orderId, // GID risolto dal lookup
|
||||
@@ -365,8 +393,18 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
} catch {
|
||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
||||
}
|
||||
} else {
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "withdrawal_duplicate",
|
||||
detail: match.orderName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,7 +442,28 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
console.error("[recesso] SMTP shop non decifrabile: uso default app");
|
||||
}
|
||||
}
|
||||
const receipt = await sendWithdrawalReceipt({
|
||||
// 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,
|
||||
@@ -421,16 +480,26 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
operational,
|
||||
smtp,
|
||||
});
|
||||
}
|
||||
|
||||
let resent = false;
|
||||
try {
|
||||
if (receipt.ok) {
|
||||
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: created.id },
|
||||
where: { id: record.id },
|
||||
data: { receiptSentAt: new Date() },
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: { shop, event: "receipt_sent", detail: match.orderName },
|
||||
});
|
||||
} else {
|
||||
}
|
||||
} 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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user