Compare commits
6 Commits
a550d8ff2e
...
f3b3b1abc3
| Author | SHA1 | Date | |
|---|---|---|---|
| f3b3b1abc3 | |||
| 3bb7a30c1d | |||
| ad9f76b6be | |||
| ce36f1a657 | |||
| 5fb9bf494c | |||
| 04a43182cb |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -27,3 +27,9 @@ app/prisma/*.sqlite*
|
||||
|
||||
# OS / editor cruft
|
||||
.DS_Store
|
||||
|
||||
# Credenziali locali - MAI committare
|
||||
Cred Fly
|
||||
*[Cc]red*
|
||||
*.secret
|
||||
token.txt
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.cache
|
||||
build
|
||||
node_modules
|
||||
.env
|
||||
.env.*
|
||||
*[Cc]red*
|
||||
.shopify
|
||||
|
||||
47
app/app/lib/crypto.server.ts
Normal file
47
app/app/lib/crypto.server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Cifratura simmetrica per segreti a riposo (es. password SMTP per-shop).
|
||||
* AES-256-GCM. Chiave da env APP_ENCRYPTION_KEY (qualsiasi lunghezza: derivata
|
||||
* a 32 byte via SHA-256). In prod = `fly secrets set APP_ENCRYPTION_KEY=...`.
|
||||
*/
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from "node:crypto";
|
||||
|
||||
const PREFIX = "enc:v1:";
|
||||
|
||||
function key(): Buffer {
|
||||
const raw = process.env.APP_ENCRYPTION_KEY;
|
||||
if (!raw || raw.length < 16) {
|
||||
throw new Error("APP_ENCRYPTION_KEY mancante o troppo corta (>=16 char)");
|
||||
}
|
||||
return createHash("sha256").update(raw).digest();
|
||||
}
|
||||
|
||||
/** Cifra -> "enc:v1:<iv>:<tag>:<data>" (base64). */
|
||||
export function encryptSecret(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
||||
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${enc.toString("base64")}`;
|
||||
}
|
||||
|
||||
/** Decifra; se non e' nel formato cifrato, ritorna il valore invariato. */
|
||||
export function decryptSecret(value: string): string {
|
||||
if (!value.startsWith(PREFIX)) return value;
|
||||
const parts = value.split(":");
|
||||
if (parts.length !== 5) return "";
|
||||
const iv = Buffer.from(parts[2]!, "base64");
|
||||
const tag = Buffer.from(parts[3]!, "base64");
|
||||
const data = Buffer.from(parts[4]!, "base64");
|
||||
const decipher = createDecipheriv("aes-256-gcm", key(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export const SECRET_PREFIX = PREFIX;
|
||||
@@ -18,22 +18,67 @@ import {
|
||||
type OperationalConfig,
|
||||
} from "./emailTemplate";
|
||||
|
||||
function buildTransport() {
|
||||
const host = process.env.SMTP_HOST;
|
||||
/** Config SMTP per-shop (password gia' DECIFRATA). Se host assente -> usa env app. */
|
||||
export interface SmtpConfig {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
user?: string | null;
|
||||
pass?: string | null;
|
||||
secure?: boolean;
|
||||
from?: string | null;
|
||||
}
|
||||
|
||||
function buildTransport(smtp?: SmtpConfig | null) {
|
||||
const useShop = !!(smtp && smtp.host && smtp.host.trim());
|
||||
const host = useShop ? smtp!.host!.trim() : process.env.SMTP_HOST;
|
||||
if (!host) return null;
|
||||
const port = Number(process.env.SMTP_PORT ?? 587);
|
||||
const user = process.env.SMTP_USER;
|
||||
const port = useShop
|
||||
? Number(smtp!.port ?? 587)
|
||||
: Number(process.env.SMTP_PORT ?? 587);
|
||||
const secure = useShop ? !!smtp!.secure : process.env.SMTP_SECURE === "true";
|
||||
const user = useShop ? smtp!.user : process.env.SMTP_USER;
|
||||
const pass = useShop ? smtp!.pass : process.env.SMTP_PASS;
|
||||
return nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
auth: user ? { user, pass: process.env.SMTP_PASS ?? "" } : undefined,
|
||||
secure,
|
||||
auth: user ? { user, pass: pass ?? "" } : undefined,
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
socketTimeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
function mailFrom(smtp?: SmtpConfig | null): string {
|
||||
return (
|
||||
(smtp?.from && smtp.from.trim()) ||
|
||||
process.env.MAIL_FROM ||
|
||||
"no-reply@localhost"
|
||||
);
|
||||
}
|
||||
|
||||
type Transport = NonNullable<ReturnType<typeof buildTransport>>;
|
||||
|
||||
/** Invio con retry (backoff lineare). Riduce le ricevute perse per glitch SMTP. */
|
||||
async function trySend(
|
||||
transport: Transport,
|
||||
message: Parameters<Transport["sendMail"]>[0],
|
||||
attempts = 3,
|
||||
) {
|
||||
let lastErr: unknown;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await transport.sendMail(message);
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (i < attempts - 1) {
|
||||
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
/** Versione testo grezza dell'HTML (fallback per client senza HTML). */
|
||||
function htmlToText(html: string): string {
|
||||
return html
|
||||
@@ -67,8 +112,9 @@ export async function sendWithdrawalReceipt(params: {
|
||||
intro?: string | null;
|
||||
note?: string | null;
|
||||
operational?: OperationalConfig | null;
|
||||
smtp?: SmtpConfig | null;
|
||||
}): Promise<ReceiptResult> {
|
||||
const transport = buildTransport();
|
||||
const transport = buildTransport(params.smtp);
|
||||
if (!transport) {
|
||||
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
||||
}
|
||||
@@ -83,8 +129,8 @@ export async function sendWithdrawalReceipt(params: {
|
||||
const text = htmlToText(html);
|
||||
|
||||
try {
|
||||
const info = await transport.sendMail({
|
||||
from: process.env.MAIL_FROM ?? "no-reply@localhost",
|
||||
const info = await trySend(transport, {
|
||||
from: mailFrom(params.smtp),
|
||||
to: params.to,
|
||||
subject,
|
||||
text,
|
||||
@@ -118,9 +164,10 @@ export async function sendMerchantNotification(params: {
|
||||
customerEmail: string;
|
||||
orderUrl: string;
|
||||
transmittedAt: string;
|
||||
returnStatus: "created" | "no_returnable" | "error";
|
||||
returnStatus: "created" | "no_returnable" | "exists" | "error";
|
||||
smtp?: SmtpConfig | null;
|
||||
}): Promise<ReceiptResult> {
|
||||
const transport = buildTransport();
|
||||
const transport = buildTransport(params.smtp);
|
||||
if (!transport) {
|
||||
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
||||
}
|
||||
@@ -128,9 +175,11 @@ export async function sendMerchantNotification(params: {
|
||||
const actionLine =
|
||||
params.returnStatus === "created"
|
||||
? "E' stato creato un reso nell'ordine: gestiscilo dalla pagina dell'ordine."
|
||||
: params.returnStatus === "no_returnable"
|
||||
? "L'ordine non risulta evaso: valuta annullamento o rimborso."
|
||||
: "Reso non creato automaticamente: verifica manualmente l'ordine.";
|
||||
: params.returnStatus === "exists"
|
||||
? "Esiste gia' un reso per questo ordine: gestiscilo dalla pagina dell'ordine."
|
||||
: params.returnStatus === "no_returnable"
|
||||
? "L'ordine non risulta evaso: valuta annullamento o rimborso."
|
||||
: "Reso non creato automaticamente: verifica manualmente l'ordine.";
|
||||
|
||||
const orderBtn = /^https?:\/\//i.test(params.orderUrl)
|
||||
? `<p style="margin:16px 0 0;"><a href="${escM(params.orderUrl)}" style="display:inline-block;padding:10px 18px;background:#1a1a1a;color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">Apri l'ordine</a></p>`
|
||||
@@ -151,6 +200,7 @@ export async function sendMerchantNotification(params: {
|
||||
<div><span style="color:#777;">Trasmesso:</span> ${escM(params.transmittedAt)}</div>
|
||||
</td></tr></table>
|
||||
<p style="margin:16px 0 0;font-size:14px;line-height:1.6;color:#3a3a3a;">${actionLine}</p>
|
||||
<p style="margin:10px 0 0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Promemoria: disponi il rimborso entro 14 giorni dalla richiesta (art. 56 Cod. Consumo). Puoi trattenerlo fino alla riconsegna della merce o alla prova di spedizione da parte del cliente.</p>
|
||||
${orderBtn}
|
||||
</td></tr></table>
|
||||
</td></tr></table>
|
||||
@@ -161,14 +211,15 @@ ${orderBtn}
|
||||
`Cliente: ${params.customerName} (${params.customerEmail})`,
|
||||
`Trasmesso: ${params.transmittedAt}`,
|
||||
actionLine,
|
||||
"Promemoria: rimborso entro 14 giorni dalla richiesta (art. 56); puoi trattenere fino alla riconsegna della merce o alla prova di spedizione.",
|
||||
/^https?:\/\//i.test(params.orderUrl) ? params.orderUrl : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
try {
|
||||
const info = await transport.sendMail({
|
||||
from: process.env.MAIL_FROM ?? "no-reply@localhost",
|
||||
const info = await trySend(transport, {
|
||||
from: mailFrom(params.smtp),
|
||||
to: params.to,
|
||||
subject,
|
||||
text,
|
||||
|
||||
@@ -58,11 +58,14 @@ export function successMessage(
|
||||
orderName: string,
|
||||
transmittedAt: string,
|
||||
email: string,
|
||||
receiptSent = true,
|
||||
): { line1: string; line2: string; line3: string } {
|
||||
return {
|
||||
line1: "Recesso trasmesso",
|
||||
line2: `Registrato per l'ordine ${orderName} il ${transmittedAt}.`,
|
||||
line3: `Ti abbiamo inviato una ricevuta a ${email}.`,
|
||||
line3: receiptSent
|
||||
? `Ti abbiamo inviato una ricevuta a ${email}.`
|
||||
: `La ricevuta verrà inviata a ${email}. Se non la ricevi a breve, contattaci.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -98,9 +98,20 @@ const RATE_MAX_ATTEMPTS = 8; // tentativi di lookup per finestra, per shop+IP
|
||||
const rateBucket = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
/** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */
|
||||
let lastRatePruneAt = 0;
|
||||
/** Rimuove le voci scadute dal bucket (evita crescita illimitata della Map). */
|
||||
function pruneRateBucket(now: number): void {
|
||||
if (now - lastRatePruneAt < 60_000) return;
|
||||
lastRatePruneAt = now;
|
||||
for (const [k, v] of rateBucket) {
|
||||
if (v.resetAt <= now) rateBucket.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
export function checkRateLimit(shop: string, ip: string): boolean {
|
||||
const key = `${shop}:${ip}`;
|
||||
const now = Date.now();
|
||||
pruneRateBucket(now);
|
||||
const entry = rateBucket.get(key);
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateBucket.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
||||
@@ -356,6 +367,9 @@ export async function getShopInfo(admin: AdminApiContext): Promise<ShopInfo> {
|
||||
const RETURNABLE_QUERY = `#graphql
|
||||
query recessoOrderFulfillments($orderId: ID!) {
|
||||
order(id: $orderId) {
|
||||
returns(first: 1) {
|
||||
edges { node { id } }
|
||||
}
|
||||
fulfillments(first: 10) {
|
||||
fulfillmentLineItems(first: 50) {
|
||||
edges {
|
||||
@@ -380,6 +394,7 @@ const RETURN_CREATE_MUTATION = `#graphql
|
||||
interface ReturnableGraphQL {
|
||||
data?: {
|
||||
order?: {
|
||||
returns?: { edges?: Array<unknown> | null } | null;
|
||||
fulfillments?: Array<{
|
||||
fulfillmentLineItems?: {
|
||||
edges?: Array<{
|
||||
@@ -402,6 +417,7 @@ interface ReturnCreateGraphQL {
|
||||
export type ReturnCreation =
|
||||
| { status: "created"; returnId: string }
|
||||
| { status: "no_returnable" }
|
||||
| { status: "exists" } // esiste gia' un reso per l'ordine
|
||||
| { status: "error"; error: string };
|
||||
|
||||
/**
|
||||
@@ -418,6 +434,10 @@ export async function createShopifyReturn(
|
||||
variables: { orderId: orderGid },
|
||||
});
|
||||
const qBody = (await qRes.json()) as ReturnableGraphQL;
|
||||
// Se esiste gia' un reso per l'ordine, non crearne un altro (evita errore fuorviante).
|
||||
if ((qBody.data?.order?.returns?.edges ?? []).length > 0) {
|
||||
return { status: "exists" };
|
||||
}
|
||||
const returnLineItems: Array<{
|
||||
fulfillmentLineItemId: string;
|
||||
quantity: number;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { TitleBar } from "@shopify/app-bridge-react";
|
||||
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { encryptSecret } from "../lib/crypto.server";
|
||||
import {
|
||||
DEFAULT_INTRO,
|
||||
DEFAULT_NOTE,
|
||||
@@ -58,6 +59,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
returnAddress: s?.returnAddress ?? "",
|
||||
opTextUnfulfilled: s?.opTextUnfulfilled ?? DEFAULT_OP_UNFULFILLED,
|
||||
opTextShipped: s?.opTextShipped ?? DEFAULT_OP_SHIPPED,
|
||||
smtpHost: s?.smtpHost ?? "",
|
||||
smtpPort: s?.smtpPort != null ? String(s.smtpPort) : "",
|
||||
smtpUser: s?.smtpUser ?? "",
|
||||
smtpSecure: s?.smtpSecure ?? false,
|
||||
smtpFrom: s?.smtpFrom ?? "",
|
||||
smtpPassSet: !!s?.smtpPass,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -90,12 +97,24 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
opTextUnfulfilled:
|
||||
opUnf && opUnf !== DEFAULT_OP_UNFULFILLED ? opUnf : null,
|
||||
opTextShipped: opShip && opShip !== DEFAULT_OP_SHIPPED ? opShip : null,
|
||||
smtpHost: String(f.get("smtpHost") ?? "").trim() || null,
|
||||
smtpPort:
|
||||
Number(f.get("smtpPort")) > 0 ? Math.trunc(Number(f.get("smtpPort"))) : null,
|
||||
smtpUser: String(f.get("smtpUser") ?? "").trim() || null,
|
||||
smtpSecure: f.get("smtpSecure") === "true",
|
||||
smtpFrom: String(f.get("smtpFrom") ?? "").trim() || null,
|
||||
};
|
||||
|
||||
// Password SMTP: cifrata solo se fornita; vuota = invariata.
|
||||
const newPass = String(f.get("smtpPass") ?? "").trim();
|
||||
const finalData = newPass
|
||||
? { ...data, smtpPass: encryptSecret(newPass) }
|
||||
: data;
|
||||
|
||||
await db.settings.upsert({
|
||||
where: { shop: session.shop },
|
||||
create: { shop: session.shop, ...data },
|
||||
update: data,
|
||||
create: { shop: session.shop, ...finalData },
|
||||
update: finalData,
|
||||
});
|
||||
return { ok: true };
|
||||
};
|
||||
@@ -143,6 +162,12 @@ export default function SettingsPage() {
|
||||
const [returnAddress, setReturnAddress] = useState(d.returnAddress);
|
||||
const [opTextUnfulfilled, setOpTextUnfulfilled] = useState(d.opTextUnfulfilled);
|
||||
const [opTextShipped, setOpTextShipped] = useState(d.opTextShipped);
|
||||
const [smtpHost, setSmtpHost] = useState(d.smtpHost);
|
||||
const [smtpPort, setSmtpPort] = useState(d.smtpPort);
|
||||
const [smtpUser, setSmtpUser] = useState(d.smtpUser);
|
||||
const [smtpPass, setSmtpPass] = useState("");
|
||||
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
|
||||
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
|
||||
const saving = nav.state === "submitting";
|
||||
@@ -206,6 +231,12 @@ export default function SettingsPage() {
|
||||
fd.set("returnAddress", returnAddress);
|
||||
fd.set("opTextUnfulfilled", opTextUnfulfilled);
|
||||
fd.set("opTextShipped", opTextShipped);
|
||||
fd.set("smtpHost", smtpHost);
|
||||
fd.set("smtpPort", smtpPort);
|
||||
fd.set("smtpUser", smtpUser);
|
||||
fd.set("smtpPass", smtpPass);
|
||||
fd.set("smtpSecure", String(smtpSecure));
|
||||
fd.set("smtpFrom", smtpFrom);
|
||||
submit(fd, { method: "post" });
|
||||
};
|
||||
|
||||
@@ -229,6 +260,7 @@ export default function SettingsPage() {
|
||||
{ id: "notifiche", content: "Notifiche" },
|
||||
{ id: "regole", content: "Regole recesso" },
|
||||
{ id: "reso", content: "Reso e stato ordine" },
|
||||
{ id: "smtp", content: "Email (SMTP)" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -470,6 +502,69 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
</BlockStack>
|
||||
) : null}
|
||||
|
||||
{tab === 4 ? (
|
||||
<Card>
|
||||
<BlockStack gap="400">
|
||||
<BlockStack gap="100">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Email (SMTP)
|
||||
</Text>
|
||||
<Text as="p" tone="subdued">
|
||||
Vuoto = provider di default dell'app. Compila per inviare dal
|
||||
tuo SMTP (email dal tuo dominio). La password è cifrata a
|
||||
riposo.
|
||||
</Text>
|
||||
</BlockStack>
|
||||
<TextField
|
||||
label="Host SMTP"
|
||||
value={smtpHost}
|
||||
onChange={setSmtpHost}
|
||||
autoComplete="off"
|
||||
placeholder="smtp-relay.brevo.com"
|
||||
/>
|
||||
<TextField
|
||||
label="Porta"
|
||||
type="number"
|
||||
value={smtpPort}
|
||||
onChange={setSmtpPort}
|
||||
autoComplete="off"
|
||||
placeholder="587"
|
||||
/>
|
||||
<TextField
|
||||
label="Utente"
|
||||
value={smtpUser}
|
||||
onChange={setSmtpUser}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={smtpPass}
|
||||
onChange={setSmtpPass}
|
||||
autoComplete="off"
|
||||
helpText={
|
||||
d.smtpPassSet
|
||||
? "Impostata. Lascia vuoto per non cambiarla."
|
||||
: "Non impostata."
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Connessione sicura diretta (SSL/TLS, porta 465)"
|
||||
checked={smtpSecure}
|
||||
onChange={setSmtpSecure}
|
||||
/>
|
||||
<TextField
|
||||
label="Mittente (From)"
|
||||
value={smtpFrom}
|
||||
onChange={setSmtpFrom}
|
||||
autoComplete="off"
|
||||
placeholder="Il tuo negozio <no-reply@tuodominio.it>"
|
||||
/>
|
||||
{saveBtn}
|
||||
</BlockStack>
|
||||
</Card>
|
||||
) : null}
|
||||
</BlockStack>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DataTable,
|
||||
Text,
|
||||
Badge,
|
||||
Banner,
|
||||
BlockStack,
|
||||
} from "@shopify/polaris";
|
||||
import { TitleBar } from "@shopify/app-bridge-react";
|
||||
@@ -15,6 +16,18 @@ import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { formatTransmittedAt } from "../lib/recesso.server";
|
||||
|
||||
// Scadenza rimborso (Art. 56): trasmissione + 14 giorni, data Europe/Rome.
|
||||
function refundBy(transmittedAt: Date): string {
|
||||
const d = new Date(transmittedAt);
|
||||
d.setDate(d.getDate() + 14);
|
||||
return new Intl.DateTimeFormat("it-IT", {
|
||||
timeZone: "Europe/Rome",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const items = await db.withdrawalRequest.findMany({
|
||||
@@ -29,6 +42,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
customerName: w.customerName,
|
||||
email: w.email,
|
||||
transmittedAt: formatTransmittedAt(w.transmittedAt),
|
||||
refundBy: refundBy(w.transmittedAt),
|
||||
receiptSent: !!w.receiptSentAt,
|
||||
hasReturn: !!w.shopifyReturnId,
|
||||
})),
|
||||
@@ -43,6 +57,7 @@ export default function WithdrawalsPage() {
|
||||
r.customerName,
|
||||
r.email,
|
||||
r.transmittedAt,
|
||||
r.refundBy,
|
||||
r.receiptSent ? "Inviata" : "-",
|
||||
r.hasReturn ? "Sì" : "-",
|
||||
]);
|
||||
@@ -68,6 +83,11 @@ export default function WithdrawalsPage() {
|
||||
<Text as="h2" variant="headingMd">
|
||||
Registro recessi ({rows.length})
|
||||
</Text>
|
||||
<Banner tone="warning">
|
||||
Ricorda: disponi il rimborso entro 14 giorni dalla richiesta
|
||||
(art. 56). Puoi trattenere fino alla riconsegna della merce o
|
||||
alla prova di spedizione.
|
||||
</Banner>
|
||||
<DataTable
|
||||
columnContentTypes={[
|
||||
"text",
|
||||
@@ -76,12 +96,14 @@ export default function WithdrawalsPage() {
|
||||
"text",
|
||||
"text",
|
||||
"text",
|
||||
"text",
|
||||
]}
|
||||
headings={[
|
||||
"Ordine",
|
||||
"Cliente",
|
||||
"Email",
|
||||
"Trasmesso",
|
||||
"Rimborsa entro",
|
||||
"Ricevuta",
|
||||
"Reso",
|
||||
]}
|
||||
|
||||
@@ -20,7 +20,9 @@ import db from "../db.server";
|
||||
import {
|
||||
sendMerchantNotification,
|
||||
sendWithdrawalReceipt,
|
||||
type SmtpConfig,
|
||||
} from "../lib/mailer.server";
|
||||
import { decryptSecret } from "../lib/crypto.server";
|
||||
import {
|
||||
ERROR,
|
||||
EXCLUSION_REASON,
|
||||
@@ -381,6 +383,22 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
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: {
|
||||
@@ -396,6 +414,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
intro: settings?.emailIntro,
|
||||
note: settings?.emailNote,
|
||||
operational,
|
||||
smtp,
|
||||
});
|
||||
try {
|
||||
if (receipt.ok) {
|
||||
@@ -412,11 +431,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
data: {
|
||||
shop,
|
||||
event: "receipt_failed",
|
||||
detail: receipt.error.slice(0, 200),
|
||||
// no PII in audit: l'errore SMTP puo' contenere l'email.
|
||||
detail: "invio ricevuta fallito",
|
||||
},
|
||||
});
|
||||
// TODO(A9): coda/retry per la ricevuta fallita ("senza ritardo") +
|
||||
// messaggio di successo che rifletta l'esito reale dell'invio.
|
||||
// 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);
|
||||
@@ -426,7 +446,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
// (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" | "error" = "error";
|
||||
let returnStatus: "created" | "no_returnable" | "exists" | "error" =
|
||||
"error";
|
||||
const orderClosed =
|
||||
!!match.cancelledAt ||
|
||||
match.financialStatus === "REFUNDED" ||
|
||||
@@ -464,6 +485,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
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({
|
||||
@@ -523,12 +552,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
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",
|
||||
detail: notif.ok ? match.orderName : notif.error.slice(0, 200),
|
||||
// no PII in audit: l'errore SMTP puo' contenere l'email.
|
||||
detail: notif.ok ? match.orderName : "notifica merchant fallita",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -536,7 +570,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}
|
||||
}
|
||||
|
||||
const msg = successMessage(match.orderName, transmittedLabel, email);
|
||||
const msg = successMessage(
|
||||
match.orderName,
|
||||
transmittedLabel,
|
||||
email,
|
||||
receipt.ok,
|
||||
);
|
||||
return htmlResponse(renderStep4(msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -17,23 +17,39 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
.update(JSON.stringify(payload ?? {}))
|
||||
.digest("hex");
|
||||
|
||||
// Idempotency: record the webhook once (webhookId is unique when present).
|
||||
// Idempotency: se gia' lavorato, esci.
|
||||
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
|
||||
const existing = await db.webhookEvent.findUnique({
|
||||
where: { webhookId: dedupeKey },
|
||||
});
|
||||
if (existing?.processed) return new Response();
|
||||
await db.webhookEvent.upsert({
|
||||
where: { webhookId: dedupeKey },
|
||||
create: { shop, topic, webhookId: dedupeKey, processed: false },
|
||||
update: {},
|
||||
});
|
||||
|
||||
// I dati del cliente (richieste di recesso) sono consultabili dal merchant
|
||||
// (titolare) nella dashboard Recessi, che li relaziona al data subject.
|
||||
// Registriamo la richiesta e quanti record esistono.
|
||||
const email = ((payload as { customer?: { email?: unknown } } | null)?.customer
|
||||
?.email ?? null) as string | null;
|
||||
const count =
|
||||
typeof email === "string" && email
|
||||
? await db.withdrawalRequest.count({ where: { shop, email } })
|
||||
: 0;
|
||||
|
||||
await db.auditLog.create({
|
||||
data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "customers/data_request received" },
|
||||
data: {
|
||||
shop,
|
||||
event: `gdpr.${topic}`,
|
||||
payloadHash,
|
||||
detail: `customers/data_request: ${count} record disponibili nella dashboard Recessi`,
|
||||
},
|
||||
});
|
||||
await db.webhookEvent.update({
|
||||
where: { webhookId: dedupeKey },
|
||||
data: { processed: true },
|
||||
});
|
||||
|
||||
// TODO(A8): gather every stored personal data point for this customer
|
||||
// (WithdrawalRequest rows matched by email / customer id: name, email,
|
||||
// statement text, transmission timestamp, order refs) and hand it to the
|
||||
// merchant (data controller), who relays it to the data subject. Then mark
|
||||
// the WebhookEvent processed = true.
|
||||
|
||||
return new Response();
|
||||
};
|
||||
|
||||
@@ -18,22 +18,47 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
.update(JSON.stringify(payload ?? {}))
|
||||
.digest("hex");
|
||||
|
||||
// Idempotency: record the webhook once (webhookId is unique when present).
|
||||
// Idempotency: se gia' lavorato, esci.
|
||||
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
|
||||
const existing = await db.webhookEvent.findUnique({
|
||||
where: { webhookId: dedupeKey },
|
||||
});
|
||||
if (existing?.processed) return new Response();
|
||||
await db.webhookEvent.upsert({
|
||||
where: { webhookId: dedupeKey },
|
||||
create: { shop, topic, webhookId: dedupeKey, processed: false },
|
||||
update: {},
|
||||
});
|
||||
|
||||
// Pseudonimizza la PII del cliente nelle richieste di recesso, mantenendo il
|
||||
// record legale (ordine, timestamp) come prova ex art. 54-bis. Base di
|
||||
// conservazione: obbligo legale / difesa in giudizio (art. 17(3) GDPR).
|
||||
const email = ((payload as { customer?: { email?: unknown } } | null)?.customer
|
||||
?.email ?? null) as string | null;
|
||||
let redacted = 0;
|
||||
if (typeof email === "string" && email) {
|
||||
const res = await db.withdrawalRequest.updateMany({
|
||||
where: { shop, email },
|
||||
data: {
|
||||
customerName: "[redatto]",
|
||||
email: "[redatto]",
|
||||
statementText: "[redatto]",
|
||||
},
|
||||
});
|
||||
redacted = res.count;
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "customers/redact received" },
|
||||
data: {
|
||||
shop,
|
||||
event: `gdpr.${topic}`,
|
||||
payloadHash,
|
||||
detail: `customers/redact: ${redacted} record pseudonimizzati`,
|
||||
},
|
||||
});
|
||||
await db.webhookEvent.update({
|
||||
where: { webhookId: dedupeKey },
|
||||
data: { processed: true },
|
||||
});
|
||||
|
||||
// TODO(A8): redact/anonymize this customer's PII in WithdrawalRequest
|
||||
// (customerName, email, statementText) for the given shop + customer/orders,
|
||||
// WITHOUT destroying the legal audit trail (keep AuditLog + hashed refs).
|
||||
// Then mark the WebhookEvent processed = true.
|
||||
|
||||
return new Response();
|
||||
};
|
||||
|
||||
@@ -19,22 +19,33 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
.update(JSON.stringify(payload ?? {}))
|
||||
.digest("hex");
|
||||
|
||||
// Idempotency: record the webhook once (webhookId is unique when present).
|
||||
// Idempotency: se gia' lavorato, esci.
|
||||
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
|
||||
const existing = await db.webhookEvent.findUnique({
|
||||
where: { webhookId: dedupeKey },
|
||||
});
|
||||
if (existing?.processed) return new Response();
|
||||
await db.webhookEvent.upsert({
|
||||
where: { webhookId: dedupeKey },
|
||||
create: { shop, topic, webhookId: dedupeKey, processed: false },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "shop/redact received" },
|
||||
// Purge completa dei dati dello shop (app disinstallata + ~48h). Il merchant,
|
||||
// come titolare, deve aver esportato prima cio' che gli serve. Cancelliamo
|
||||
// anche l'AuditLog: cessata la relazione, non c'e' piu' base per conservarlo.
|
||||
await db.withdrawalRequest.deleteMany({ where: { shop } });
|
||||
await db.exclusionRule.deleteMany({ where: { shop } });
|
||||
await db.settings.deleteMany({ where: { shop } });
|
||||
await db.session.deleteMany({ where: { shop } });
|
||||
await db.auditLog.deleteMany({ where: { shop } });
|
||||
await db.webhookEvent.deleteMany({
|
||||
where: { shop, webhookId: { not: dedupeKey } },
|
||||
});
|
||||
// Manteniamo SOLO la WebhookEvent corrente (marcata processata) per idempotenza.
|
||||
await db.webhookEvent.update({
|
||||
where: { webhookId: dedupeKey },
|
||||
data: { processed: true },
|
||||
});
|
||||
|
||||
// TODO(A8): delete all data for this shop (Settings, ExclusionRule,
|
||||
// WithdrawalRequest, Session, and stale WebhookEvent rows). Decide the legal
|
||||
// retention policy for AuditLog before wiring the real deletion. Then mark
|
||||
// the WebhookEvent processed = true.
|
||||
|
||||
return new Response();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Settings" ADD COLUMN "smtpFrom" TEXT,
|
||||
ADD COLUMN "smtpHost" TEXT,
|
||||
ADD COLUMN "smtpPass" TEXT,
|
||||
ADD COLUMN "smtpPort" INTEGER,
|
||||
ADD COLUMN "smtpSecure" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "smtpUser" TEXT;
|
||||
@@ -63,6 +63,12 @@ model Settings {
|
||||
returnInstructions String? // deprecato: sostituito da opTextShipped
|
||||
opTextUnfulfilled String?
|
||||
opTextShipped String?
|
||||
smtpHost String?
|
||||
smtpPort Int?
|
||||
smtpUser String?
|
||||
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
|
||||
smtpSecure Boolean @default(false)
|
||||
smtpFrom String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
client_id = "8d67c00a078a615037497ad056f7f99d"
|
||||
name = "Legal Return PCRT "
|
||||
# DEV: quick tunnel Cloudflare (no interstitial). Auto-update Dev Dashboard rotto → settato via deploy. Cambia se il tunnel riparte.
|
||||
application_url = "https://miscellaneous-connections-harvest-chronicle.trycloudflare.com"
|
||||
application_url = "https://recesso-custom.fly.dev"
|
||||
embedded = true
|
||||
|
||||
# App Proxy: lo storefront /apps/recesso viene proxato a <application_url>/proxy.
|
||||
# NB: se il tunnel Cloudflare (application_url) cambia, aggiornare anche `url` qui.
|
||||
# Richiede `shopify app deploy` perché la configurazione abbia effetto.
|
||||
[app_proxy]
|
||||
url = "https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/proxy"
|
||||
url = "https://recesso-custom.fly.dev/proxy"
|
||||
subpath = "recesso"
|
||||
prefix = "apps"
|
||||
|
||||
@@ -49,7 +49,7 @@ use_legacy_install_flow = false
|
||||
|
||||
[auth]
|
||||
redirect_urls = [
|
||||
"https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/auth/callback",
|
||||
"https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/auth/shopify/callback",
|
||||
"https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/api/auth/callback"
|
||||
"https://recesso-custom.fly.dev/auth/callback",
|
||||
"https://recesso-custom.fly.dev/auth/shopify/callback",
|
||||
"https://recesso-custom.fly.dev/api/auth/callback"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user