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).
|
// Template email ricevuta su supporto durevole (usato da A4).
|
||||||
export function receiptEmailSubject(orderName: string): string {
|
export function receiptEmailSubject(orderName: string): string {
|
||||||
return `Ricevuta della tua richiesta di recesso - Ordine ${orderName}`;
|
return `Ricevuta della tua richiesta di recesso - Ordine ${orderName}`;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import db from "../db.server";
|
|||||||
import {
|
import {
|
||||||
sendMerchantNotification,
|
sendMerchantNotification,
|
||||||
sendWithdrawalReceipt,
|
sendWithdrawalReceipt,
|
||||||
|
type ReceiptResult,
|
||||||
type SmtpConfig,
|
type SmtpConfig,
|
||||||
} from "../lib/mailer.server";
|
} from "../lib/mailer.server";
|
||||||
import { decryptSecret } from "../lib/crypto.server";
|
import { decryptSecret } from "../lib/crypto.server";
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
ERROR,
|
ERROR,
|
||||||
EXCLUSION_REASON,
|
EXCLUSION_REASON,
|
||||||
NOTICE,
|
NOTICE,
|
||||||
|
duplicateMessage,
|
||||||
exclusionMessage,
|
exclusionMessage,
|
||||||
statementTemplate,
|
statementTemplate,
|
||||||
successMessage,
|
successMessage,
|
||||||
@@ -311,6 +313,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
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):
|
// Re-verifica server-side (integrità hidden fields / anti-tamper):
|
||||||
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
|
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
|
||||||
const match = await lookupOrder(admin, orderName, email);
|
const match = await lookupOrder(admin, orderName, email);
|
||||||
@@ -324,49 +340,71 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
return htmlResponse(renderStep1({ error: block }));
|
return htmlResponse(renderStep1({ error: block }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"),
|
// IDEMPOTENZA: il diritto di recesso si esercita UNA volta per contratto.
|
||||||
// NON di ricezione. Salvato in UTC (Prisma DateTime).
|
// Se esiste gia' una richiesta per quest'ordine non ne creiamo una seconda:
|
||||||
const transmittedAt = new Date();
|
// 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;
|
let record = existing;
|
||||||
try {
|
if (!record) {
|
||||||
created = await db.withdrawalRequest.create({
|
// transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"),
|
||||||
data: {
|
// NON di ricezione. Salvato in UTC (Prisma DateTime).
|
||||||
shop, // sempre da session.shop
|
const transmittedAt = new Date();
|
||||||
orderId: match.orderId, // GID risolto dal lookup
|
try {
|
||||||
orderName: match.orderName,
|
record = await db.withdrawalRequest.create({
|
||||||
customerName,
|
data: {
|
||||||
email,
|
shop, // sempre da session.shop
|
||||||
statementText,
|
orderId: match.orderId, // GID risolto dal lookup
|
||||||
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,
|
orderName: match.orderName,
|
||||||
customerName,
|
customerName,
|
||||||
email,
|
email,
|
||||||
statementText,
|
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,
|
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À
|
// A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ
|
||||||
// persistito e valido: un invio email fallito NON deve invalidarlo.
|
// 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");
|
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
|
||||||
to: email,
|
// la ricevuta, non piu' di una volta all'ora (aiuta chi non l'ha ricevuta,
|
||||||
vars: {
|
// senza trasformare l'endpoint in un amplificatore di email).
|
||||||
shopName,
|
let shouldSend = true;
|
||||||
shopUrl: shopInfo.url,
|
if (isDuplicate) {
|
||||||
orderName: match.orderName,
|
const since = new Date(Date.now() - 60 * 60 * 1000);
|
||||||
orderUrl: match.orderUrl,
|
const recent = await db.auditLog
|
||||||
customerName,
|
.count({
|
||||||
transmittedAt: transmittedLabel,
|
where: {
|
||||||
statementText,
|
shop,
|
||||||
},
|
event: "receipt_resent",
|
||||||
subject: settings?.emailSubject,
|
detail: match.orderName,
|
||||||
intro: settings?.emailIntro,
|
createdAt: { gt: since },
|
||||||
note: settings?.emailNote,
|
},
|
||||||
operational,
|
})
|
||||||
smtp,
|
.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 {
|
try {
|
||||||
if (receipt.ok) {
|
if (receipt?.ok) {
|
||||||
await db.withdrawalRequest.update({
|
if (isDuplicate) {
|
||||||
where: { id: created.id },
|
resent = true;
|
||||||
data: { receiptSentAt: new Date() },
|
await db.auditLog.create({
|
||||||
});
|
data: { shop, event: "receipt_resent", detail: match.orderName },
|
||||||
await db.auditLog.create({
|
});
|
||||||
data: { shop, event: "receipt_sent", detail: match.orderName },
|
} else {
|
||||||
});
|
await db.withdrawalRequest.update({
|
||||||
} else {
|
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);
|
console.error("[recesso] invio ricevuta fallito:", receipt.error);
|
||||||
await db.auditLog.create({
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -440,13 +509,21 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
detail: redactErr(receipt.error),
|
detail: redactErr(receipt.error),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Retry in-request nel mailer (trySend) + messaggio di successo onesto
|
|
||||||
// (successMessage riceve receipt.ok). Coda persistente = eventuale futuro.
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[recesso] aggiornamento stato ricevuta fallito:", 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
|
// Integrazione Resi Shopify: crea un reso nativo per gli ordini evasi
|
||||||
// (best-effort; il recesso legale e' gia' registrato). Ordini annullati/
|
// (best-effort; il recesso legale e' gia' registrato). Ordini annullati/
|
||||||
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
|
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
|
||||||
@@ -472,7 +549,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
returnStatus = ret.status;
|
returnStatus = ret.status;
|
||||||
if (ret.status === "created") {
|
if (ret.status === "created") {
|
||||||
await db.withdrawalRequest.update({
|
await db.withdrawalRequest.update({
|
||||||
where: { id: created.id },
|
where: { id: record.id },
|
||||||
data: { shopifyReturnId: ret.returnId },
|
data: { shopifyReturnId: ret.returnId },
|
||||||
});
|
});
|
||||||
await db.auditLog.create({
|
await db.auditLog.create({
|
||||||
@@ -586,7 +663,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
match.orderName,
|
match.orderName,
|
||||||
transmittedLabel,
|
transmittedLabel,
|
||||||
email,
|
email,
|
||||||
receipt.ok,
|
!!receipt?.ok,
|
||||||
);
|
);
|
||||||
return htmlResponse(renderStep4(msg));
|
return htmlResponse(renderStep4(msg));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user