Resi Shopify + notifiche merchant + A6 (finestra/esclusioni) + dashboard recessi
Integrazione Resi Shopify: - Alla conferma del recesso, crea un Reso nativo (returnCreate) per gli ordini evasi -> gestione nella UI Resi nativa. Ordini non evasi: skip (merchant gestisce annullo/rimborso). Query via order.fulfillments (returnableFulfillments non esiste nella API 2026-04). Campo WithdrawalRequest.shopifyReturnId. Notifiche al merchant (dietro toggle Settings): - Email di notifica a ogni recesso (sendMerchantNotification) + tag "Recesso" sull'ordine (tagsAdd, scope write_orders). Toggle notifyEnabled/notifyEmail/tagEnabled. A6 - completezza compliance (dietro toggle, default OFF, non tocca il flusso testato): - Finestra 14gg: computeDeadline/isWindowExpired (rif = data evasione o ordine + defaultWindowDays). Esclusioni Art. 59: checkExclusions su regole ExclusionRule (ALL/PRODUCT/TAG). lookupOrder esteso (fulfillments + lineItems product/tags). checkCompliance() aggancia lookup+confirm. Toggle enforceWindow/enforceExclusions. - Pagina admin Esclusioni (CRUD regole) + sezione Regole di recesso in Impostazioni. Dashboard: pagina Recessi (registro legale read-only). NavMenu: Recessi/Impostazioni/Esclusioni. Scope: +write_returns,+write_orders. Editor email vincolato (oggetto/intro/nota + anteprima). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
42
TEST-CHECKLIST.md
Normal file
42
TEST-CHECKLIST.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Test checklist - Notifica merchant + Tag ordine (+ Resi Shopify)
|
||||
|
||||
Da eseguire dopo lo sviluppo di questi comportamenti.
|
||||
|
||||
## 1. Riavvia dev (Prisma + nuovo scope write_orders cambiati)
|
||||
```
|
||||
q
|
||||
shopify app dev --tunnel-url https://miscellaneous-connections-harvest-chronicle.trycloudflare.com:3458
|
||||
```
|
||||
|
||||
## 2. Ri-concedi lo scope write_orders
|
||||
Apri l'app nell'admin di pcrt-reso-test -> approva il nuovo permesso (ordini).
|
||||
Se non lo chiede: disinstalla e reinstalla l'app.
|
||||
Verifica scope concessi:
|
||||
```
|
||||
docker exec recesso-pg psql -U postgres -d recesso -c "SELECT scope FROM \"Session\";"
|
||||
```
|
||||
Atteso: read_orders,read_products,write_returns,write_orders
|
||||
|
||||
## 3. Configura in Admin -> Impostazioni
|
||||
- Attiva "Invia email di notifica a ogni recesso"
|
||||
- Metti un'email in "Email notifiche"
|
||||
- Attiva "Aggiungi il tag 'Recesso' all'ordine"
|
||||
- Salva
|
||||
|
||||
## 4. Esegui recesso dallo storefront
|
||||
- Una volta su ordine EVASO
|
||||
- Una volta su ordine NON evaso
|
||||
|
||||
## 5. Verifica
|
||||
- Mailpit (http://localhost:18025): 2 email -> ricevuta cliente + notifica merchant
|
||||
- Ordine (Shopify admin): tag "Recesso" presente (filtrabile in lista ordini)
|
||||
- Ordine evaso: Reso creato (Ordini -> apri ordine -> blocco Reso)
|
||||
- Audit:
|
||||
```
|
||||
docker exec recesso-pg psql -U postgres -d recesso -c "SELECT event, LEFT(detail,60) as detail, \"createdAt\" FROM \"AuditLog\" ORDER BY \"createdAt\" DESC LIMIT 10;"
|
||||
```
|
||||
Attesi: order_tagged, merchant_notified, shopify_return_created (evaso) / shopify_return_skipped (non evaso)
|
||||
|
||||
## Note
|
||||
- I toggle in Impostazioni disattivano singolarmente notifica email e tag.
|
||||
- Il recesso legale (record + ricevuta cliente) e' indipendente: resta valido anche se tag/notifica/reso falliscono.
|
||||
@@ -91,3 +91,87 @@ export async function sendWithdrawalReceipt(params: {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function escM(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica al merchant di una nuova richiesta di recesso. Non lancia mai.
|
||||
* `returnStatus` riflette l'esito dell'integrazione Resi (per suggerire l'azione).
|
||||
*/
|
||||
export async function sendMerchantNotification(params: {
|
||||
to: string;
|
||||
orderName: string;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
orderUrl: string;
|
||||
transmittedAt: string;
|
||||
returnStatus: "created" | "no_returnable" | "error";
|
||||
}): Promise<ReceiptResult> {
|
||||
const transport = buildTransport();
|
||||
if (!transport) {
|
||||
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
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>`
|
||||
: "";
|
||||
|
||||
const subject = `Nuovo recesso - Ordine ${params.orderName}`;
|
||||
const html = `<!doctype html><html lang="it"><head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:24px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center">
|
||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="width:100%;max-width:520px;background:#fff;border:1px solid #e5e5e5;border-radius:12px;">
|
||||
<tr><td style="padding:24px 28px;">
|
||||
<h1 style="margin:0 0 12px;font-size:19px;">Nuova richiesta di recesso</h1>
|
||||
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#3a3a3a;">Un cliente ha esercitato il diritto di recesso.</p>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f6f7;border-radius:8px;"><tr><td style="padding:14px 16px;font-size:14px;line-height:1.9;color:#444;">
|
||||
<div><span style="color:#777;">Ordine:</span> <strong>${escM(params.orderName)}</strong></div>
|
||||
<div><span style="color:#777;">Cliente:</span> <strong>${escM(params.customerName)}</strong></div>
|
||||
<div><span style="color:#777;">Email:</span> ${escM(params.customerEmail)}</div>
|
||||
<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>
|
||||
${orderBtn}
|
||||
</td></tr></table>
|
||||
</td></tr></table>
|
||||
</body></html>`;
|
||||
const text = [
|
||||
"Nuova richiesta di recesso",
|
||||
`Ordine: ${params.orderName}`,
|
||||
`Cliente: ${params.customerName} (${params.customerEmail})`,
|
||||
`Trasmesso: ${params.transmittedAt}`,
|
||||
actionLine,
|
||||
/^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",
|
||||
to: params.to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
return { ok: true, messageId: info.messageId };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : "invio notifica fallito",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export const EXCLUSION_REASON = {
|
||||
PERISHABLE: "prodotto deperibile o a rapida scadenza",
|
||||
HYGIENE:
|
||||
"prodotto sigillato, aperto dopo la consegna, non restituibile per motivi igienici o di salute",
|
||||
OTHER: "prodotto escluso dal diritto di recesso",
|
||||
} as const;
|
||||
|
||||
export function exclusionMessage(motivoEsclusione: string): string {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
|
||||
import type { ExclusionRule } from "@prisma/client";
|
||||
import { FIELD, INFO_TITLE, INFO_BODY, CONFIRM_LABEL, PAGE_TITLE } from "./recesso.copy";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -122,6 +123,81 @@ export interface MatchedOrder {
|
||||
email: string; // email dell'ordine (per precompilazione)
|
||||
createdAt: string;
|
||||
orderUrl: string; // URL pagina di stato dell'ordine (link per il cliente)
|
||||
fulfilledAt: string | null; // data ultima evasione (riferimento finestra), null se non evaso
|
||||
lineItems: Array<{ productId: string | null; tags: string[] }>;
|
||||
}
|
||||
|
||||
// --- A6: finestra di recesso (deadline engine) ---------------------------
|
||||
// Riferimento = data di evasione (consegna ~ ricezione beni) se disponibile,
|
||||
// altrimenti data ordine (fallback conservativo). Scadenza = riferimento + giorni.
|
||||
// NB: l'estensione a 12 mesi + 14gg per mancata informativa (Art. 49) NON e'
|
||||
// gestita qui: l'app FORNISCE l'informativa, quindi vale il termine ordinario.
|
||||
export function computeDeadline(
|
||||
match: MatchedOrder,
|
||||
windowDays: number,
|
||||
): Date | null {
|
||||
const ref = match.fulfilledAt || match.createdAt;
|
||||
if (!ref) return null;
|
||||
const d = new Date(ref);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setUTCDate(d.getUTCDate() + windowDays);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function isWindowExpired(
|
||||
match: MatchedOrder,
|
||||
windowDays: number,
|
||||
now: Date,
|
||||
): boolean {
|
||||
const deadline = computeDeadline(match, windowDays);
|
||||
return deadline ? now.getTime() > deadline.getTime() : false;
|
||||
}
|
||||
|
||||
// --- A6: esclusioni Art. 59 ----------------------------------------------
|
||||
export interface ExclusionCheck {
|
||||
fullyExcluded: boolean; // tutte le righe escluse -> recesso non ammesso
|
||||
anyExcluded: boolean; // almeno una riga esclusa -> recesso parziale
|
||||
reasons: string[]; // ragioni distinte (ExclusionReason)
|
||||
}
|
||||
|
||||
function ruleMatchesLine(
|
||||
rule: ExclusionRule,
|
||||
li: { productId: string | null; tags: string[] },
|
||||
): boolean {
|
||||
switch (rule.scope) {
|
||||
case "ALL":
|
||||
return true;
|
||||
case "PRODUCT":
|
||||
return !!rule.targetId && li.productId === rule.targetId;
|
||||
case "TAG":
|
||||
return !!rule.targetId && li.tags.includes(rule.targetId);
|
||||
default:
|
||||
return false; // COLLECTION non supportato in MVP
|
||||
}
|
||||
}
|
||||
|
||||
export function checkExclusions(
|
||||
match: MatchedOrder,
|
||||
rules: ExclusionRule[],
|
||||
): ExclusionCheck {
|
||||
const active = rules.filter((r) => r.active);
|
||||
if (!active.length || !match.lineItems.length) {
|
||||
return { fullyExcluded: false, anyExcluded: false, reasons: [] };
|
||||
}
|
||||
const reasons = new Set<string>();
|
||||
let excluded = 0;
|
||||
for (const li of match.lineItems) {
|
||||
const rule = active.find((r) => ruleMatchesLine(r, li));
|
||||
if (rule) {
|
||||
excluded++;
|
||||
reasons.add(rule.reason);
|
||||
}
|
||||
}
|
||||
return {
|
||||
fullyExcluded: excluded === match.lineItems.length,
|
||||
anyExcluded: excluded > 0,
|
||||
reasons: [...reasons],
|
||||
};
|
||||
}
|
||||
|
||||
interface OrderLookupGraphQL {
|
||||
@@ -134,6 +210,16 @@ interface OrderLookupGraphQL {
|
||||
email?: string | null;
|
||||
createdAt?: string | null;
|
||||
statusPageUrl?: string | null;
|
||||
displayFulfillmentStatus?: string | null;
|
||||
fulfillments?: Array<{ createdAt?: string | null } | null> | null;
|
||||
lineItems?: {
|
||||
edges?: Array<{
|
||||
node?: {
|
||||
quantity?: number | null;
|
||||
product?: { id?: string | null; tags?: string[] | null } | null;
|
||||
} | null;
|
||||
} | null> | null;
|
||||
} | null;
|
||||
} | null;
|
||||
} | null> | null;
|
||||
} | null;
|
||||
@@ -150,6 +236,21 @@ const ORDER_LOOKUP_QUERY = `#graphql
|
||||
email
|
||||
createdAt
|
||||
statusPageUrl
|
||||
displayFulfillmentStatus
|
||||
fulfillments(first: 10) {
|
||||
createdAt
|
||||
}
|
||||
lineItems(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
quantity
|
||||
product {
|
||||
id
|
||||
tags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +310,164 @@ export async function getShopInfo(admin: AdminApiContext): Promise<ShopInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integrazione Resi Shopify -------------------------------------------
|
||||
const RETURNABLE_QUERY = `#graphql
|
||||
query recessoOrderFulfillments($orderId: ID!) {
|
||||
order(id: $orderId) {
|
||||
fulfillments(first: 10) {
|
||||
fulfillmentLineItems(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const RETURN_CREATE_MUTATION = `#graphql
|
||||
mutation recessoReturnCreate($returnInput: ReturnInput!) {
|
||||
returnCreate(returnInput: $returnInput) {
|
||||
return { id status }
|
||||
userErrors { field message }
|
||||
}
|
||||
}`;
|
||||
|
||||
interface ReturnableGraphQL {
|
||||
data?: {
|
||||
order?: {
|
||||
fulfillments?: Array<{
|
||||
fulfillmentLineItems?: {
|
||||
edges?: Array<{
|
||||
node?: { id?: string | null; quantity?: number | null } | null;
|
||||
} | null> | null;
|
||||
} | null;
|
||||
} | null> | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
interface ReturnCreateGraphQL {
|
||||
data?: {
|
||||
returnCreate?: {
|
||||
return?: { id?: string | null; status?: string | null } | null;
|
||||
userErrors?: Array<{ field?: string[] | null; message?: string | null }> | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type ReturnCreation =
|
||||
| { status: "created"; returnId: string }
|
||||
| { status: "no_returnable" }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
/**
|
||||
* Crea un Reso Shopify nativo per l'ordine (solo articoli evasi). Best-effort:
|
||||
* il recesso legale e' gia' registrato a prescindere. Ordini non evasi -> nessun
|
||||
* reso (il merchant gestisce annullo/rimborso).
|
||||
*/
|
||||
export async function createShopifyReturn(
|
||||
admin: AdminApiContext,
|
||||
orderGid: string,
|
||||
): Promise<ReturnCreation> {
|
||||
try {
|
||||
const qRes = await admin.graphql(RETURNABLE_QUERY, {
|
||||
variables: { orderId: orderGid },
|
||||
});
|
||||
const qBody = (await qRes.json()) as ReturnableGraphQL;
|
||||
const returnLineItems: Array<{
|
||||
fulfillmentLineItemId: string;
|
||||
quantity: number;
|
||||
returnReason: string;
|
||||
returnReasonNote: string;
|
||||
}> = [];
|
||||
for (const f of qBody.data?.order?.fulfillments ?? []) {
|
||||
for (const liEdge of f?.fulfillmentLineItems?.edges ?? []) {
|
||||
const flId = liEdge?.node?.id;
|
||||
const qty = liEdge?.node?.quantity ?? 0;
|
||||
if (flId && qty > 0) {
|
||||
returnLineItems.push({
|
||||
fulfillmentLineItemId: flId,
|
||||
quantity: qty,
|
||||
returnReason: "OTHER",
|
||||
returnReasonNote:
|
||||
"Recesso ai sensi dell'art. 54-bis del Codice del Consumo",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!returnLineItems.length) return { status: "no_returnable" };
|
||||
|
||||
const mRes = await admin.graphql(RETURN_CREATE_MUTATION, {
|
||||
variables: { returnInput: { orderId: orderGid, returnLineItems } },
|
||||
});
|
||||
const mBody = (await mRes.json()) as ReturnCreateGraphQL;
|
||||
const errs = mBody.data?.returnCreate?.userErrors ?? [];
|
||||
if (errs.length) {
|
||||
return {
|
||||
status: "error",
|
||||
error: errs
|
||||
.map((e) => e?.message ?? "")
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
};
|
||||
}
|
||||
const returnId = mBody.data?.returnCreate?.return?.id;
|
||||
if (!returnId) return { status: "error", error: "returnCreate: nessun id" };
|
||||
return { status: "created", returnId };
|
||||
} catch (e) {
|
||||
return {
|
||||
status: "error",
|
||||
error: e instanceof Error ? e.message : "returnCreate fallito",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ORDER_TAG_MUTATION = `#graphql
|
||||
mutation recessoTagAdd($id: ID!, $tags: [String!]!) {
|
||||
tagsAdd(id: $id, tags: $tags) {
|
||||
userErrors { field message }
|
||||
}
|
||||
}`;
|
||||
|
||||
interface TagsAddGraphQL {
|
||||
data?: {
|
||||
tagsAdd?: {
|
||||
userErrors?: Array<{ message?: string | null }> | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Aggiunge il tag "Recesso" all'ordine (visibilita' merchant, filtrabile). Best-effort. */
|
||||
export async function tagOrderRecesso(
|
||||
admin: AdminApiContext,
|
||||
orderGid: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await admin.graphql(ORDER_TAG_MUTATION, {
|
||||
variables: { id: orderGid, tags: ["Recesso"] },
|
||||
});
|
||||
const body = (await res.json()) as TagsAddGraphQL;
|
||||
const errs = body.data?.tagsAdd?.userErrors ?? [];
|
||||
if (errs.length) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errs
|
||||
.map((e) => e?.message ?? "")
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : "tagsAdd fallito",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupOrder(
|
||||
admin: AdminApiContext,
|
||||
orderInput: string,
|
||||
@@ -240,12 +499,27 @@ export async function lookupOrder(
|
||||
node.name.toLowerCase() === normalized.toLowerCase();
|
||||
const emailMatch = (node.email ?? "").trim().toLowerCase() === email;
|
||||
if (nameMatch && emailMatch) {
|
||||
const fulfillmentDates = (node.fulfillments ?? [])
|
||||
.map((f) => f?.createdAt)
|
||||
.filter((d): d is string => !!d)
|
||||
.sort();
|
||||
const lineItems = (node.lineItems?.edges ?? [])
|
||||
.map((e) => e?.node)
|
||||
.filter((n): n is NonNullable<typeof n> => !!n)
|
||||
.map((n) => ({
|
||||
productId: n.product?.id ?? null,
|
||||
tags: n.product?.tags ?? [],
|
||||
}));
|
||||
return {
|
||||
orderId: node.id,
|
||||
orderName: node.name,
|
||||
email: node.email ?? emailInput.trim(),
|
||||
createdAt: node.createdAt ?? "",
|
||||
orderUrl: node.statusPageUrl ?? "",
|
||||
fulfilledAt: fulfillmentDates.length
|
||||
? fulfillmentDates[fulfillmentDates.length - 1]
|
||||
: null,
|
||||
lineItems,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
233
app/app/routes/app.exclusions.tsx
Normal file
233
app/app/routes/app.exclusions.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState } from "react";
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import type { ExclusionReason, ExclusionScope } from "@prisma/client";
|
||||
import {
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
useNavigation,
|
||||
useSubmit,
|
||||
} from "@remix-run/react";
|
||||
import {
|
||||
Page,
|
||||
Layout,
|
||||
Card,
|
||||
Select,
|
||||
TextField,
|
||||
Button,
|
||||
Banner,
|
||||
Text,
|
||||
Badge,
|
||||
BlockStack,
|
||||
InlineStack,
|
||||
} from "@shopify/polaris";
|
||||
import { TitleBar } from "@shopify/app-bridge-react";
|
||||
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
{ label: "Tag prodotto", value: "TAG" },
|
||||
{ label: "Prodotto (ID)", value: "PRODUCT" },
|
||||
{ label: "Tutto l'ordine", value: "ALL" },
|
||||
];
|
||||
const REASON_OPTIONS = [
|
||||
{ label: "Su misura / personalizzato", value: "CUSTOM" },
|
||||
{ label: "Deperibile", value: "PERISHABLE" },
|
||||
{ label: "Sigillato / igiene", value: "HYGIENE" },
|
||||
{ label: "Altro", value: "OTHER" },
|
||||
];
|
||||
const SCOPE_LABEL: Record<string, string> = {
|
||||
TAG: "Tag",
|
||||
PRODUCT: "Prodotto",
|
||||
ALL: "Tutto l'ordine",
|
||||
COLLECTION: "Collezione",
|
||||
};
|
||||
const REASON_LABEL: Record<string, string> = {
|
||||
CUSTOM: "Su misura",
|
||||
PERISHABLE: "Deperibile",
|
||||
HYGIENE: "Igiene",
|
||||
OTHER: "Altro",
|
||||
};
|
||||
|
||||
const VALID_SCOPES = ["ALL", "PRODUCT", "TAG"];
|
||||
const VALID_REASONS = ["CUSTOM", "PERISHABLE", "HYGIENE", "OTHER"];
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const rules = await db.exclusionRule.findMany({
|
||||
where: { shop: session.shop },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return { rules };
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const form = await request.formData();
|
||||
const intent = String(form.get("intent") ?? "");
|
||||
|
||||
if (intent === "delete") {
|
||||
const id = String(form.get("id") ?? "");
|
||||
if (id) {
|
||||
await db.exclusionRule.deleteMany({ where: { id, shop: session.shop } });
|
||||
}
|
||||
return { ok: true, error: null };
|
||||
}
|
||||
|
||||
if (intent === "add") {
|
||||
const scopeRaw = String(form.get("scope") ?? "TAG");
|
||||
const reasonRaw = String(form.get("reason") ?? "OTHER");
|
||||
const target = String(form.get("target") ?? "").trim();
|
||||
const scope = (
|
||||
VALID_SCOPES.includes(scopeRaw) ? scopeRaw : "TAG"
|
||||
) as ExclusionScope;
|
||||
const reason = (
|
||||
VALID_REASONS.includes(reasonRaw) ? reasonRaw : "OTHER"
|
||||
) as ExclusionReason;
|
||||
|
||||
if (scope !== "ALL" && !target) {
|
||||
return { ok: false, error: "Inserisci il valore (tag o ID prodotto)." };
|
||||
}
|
||||
|
||||
await db.exclusionRule.create({
|
||||
data: {
|
||||
shop: session.shop,
|
||||
scope,
|
||||
reason,
|
||||
targetId: scope === "ALL" ? null : target,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
return { ok: true, error: null };
|
||||
}
|
||||
|
||||
return { ok: false, error: "Azione sconosciuta." };
|
||||
};
|
||||
|
||||
export default function ExclusionsPage() {
|
||||
const { rules } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const submit = useSubmit();
|
||||
const nav = useNavigation();
|
||||
|
||||
const [scope, setScope] = useState("TAG");
|
||||
const [target, setTarget] = useState("");
|
||||
const [reason, setReason] = useState("HYGIENE");
|
||||
|
||||
const busy = nav.state !== "idle";
|
||||
|
||||
const add = () => {
|
||||
const fd = new FormData();
|
||||
fd.set("intent", "add");
|
||||
fd.set("scope", scope);
|
||||
fd.set("target", target);
|
||||
fd.set("reason", reason);
|
||||
submit(fd, { method: "post" });
|
||||
setTarget("");
|
||||
};
|
||||
|
||||
const del = (id: string) => {
|
||||
const fd = new FormData();
|
||||
fd.set("intent", "delete");
|
||||
fd.set("id", id);
|
||||
submit(fd, { method: "post" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<TitleBar title="Esclusioni Art. 59" />
|
||||
<Layout>
|
||||
<Layout.Section>
|
||||
<BlockStack gap="400">
|
||||
{actionData && actionData.ok === false && actionData.error ? (
|
||||
<Banner tone="critical">{actionData.error}</Banner>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<BlockStack gap="400">
|
||||
<BlockStack gap="100">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Aggiungi esclusione
|
||||
</Text>
|
||||
<Text as="p" tone="subdued">
|
||||
Prodotti non soggetti a recesso (art. 59). Modo più semplice:
|
||||
tagga i prodotti (es. "no-recesso") e crea una regola Tag. Il
|
||||
blocco si applica solo se attivo nelle Impostazioni.
|
||||
</Text>
|
||||
</BlockStack>
|
||||
<InlineStack gap="300" align="start" blockAlign="end" wrap>
|
||||
<Select
|
||||
label="Tipo"
|
||||
options={SCOPE_OPTIONS}
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
/>
|
||||
{scope !== "ALL" ? (
|
||||
<TextField
|
||||
label={scope === "TAG" ? "Tag prodotto" : "ID prodotto"}
|
||||
value={target}
|
||||
onChange={setTarget}
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
scope === "TAG" ? "no-recesso" : "gid://shopify/Product/…"
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Select
|
||||
label="Motivo"
|
||||
options={REASON_OPTIONS}
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
/>
|
||||
<Button variant="primary" onClick={add} loading={busy}>
|
||||
Aggiungi
|
||||
</Button>
|
||||
</InlineStack>
|
||||
</BlockStack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<BlockStack gap="300">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Regole attive ({rules.length})
|
||||
</Text>
|
||||
{rules.length === 0 ? (
|
||||
<Text as="p" tone="subdued">
|
||||
Nessuna regola.
|
||||
</Text>
|
||||
) : (
|
||||
<BlockStack gap="200">
|
||||
{rules.map((r) => (
|
||||
<InlineStack
|
||||
key={r.id}
|
||||
align="space-between"
|
||||
blockAlign="center"
|
||||
>
|
||||
<InlineStack gap="200" blockAlign="center" wrap>
|
||||
<Badge>{SCOPE_LABEL[r.scope] ?? r.scope}</Badge>
|
||||
{r.targetId ? (
|
||||
<Text as="span">{r.targetId}</Text>
|
||||
) : null}
|
||||
<Badge tone="info">
|
||||
{REASON_LABEL[r.reason] ?? r.reason}
|
||||
</Badge>
|
||||
</InlineStack>
|
||||
<Button
|
||||
variant="plain"
|
||||
tone="critical"
|
||||
onClick={() => del(r.id)}
|
||||
>
|
||||
Elimina
|
||||
</Button>
|
||||
</InlineStack>
|
||||
))}
|
||||
</BlockStack>
|
||||
)}
|
||||
</BlockStack>
|
||||
</Card>
|
||||
</BlockStack>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
BlockStack,
|
||||
InlineStack,
|
||||
Badge,
|
||||
Checkbox,
|
||||
} from "@shopify/polaris";
|
||||
import { TitleBar } from "@shopify/app-bridge-react";
|
||||
|
||||
@@ -40,6 +41,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
subject: settings?.emailSubject ?? DEFAULT_SUBJECT,
|
||||
intro: settings?.emailIntro ?? DEFAULT_INTRO,
|
||||
note: settings?.emailNote ?? DEFAULT_NOTE,
|
||||
notifyEnabled: settings?.notifyEnabled ?? true,
|
||||
notifyEmail: settings?.notifyEmail ?? "",
|
||||
tagEnabled: settings?.tagEnabled ?? true,
|
||||
enforceWindow: settings?.enforceWindow ?? false,
|
||||
windowDays: settings?.defaultWindowDays ?? 14,
|
||||
enforceExclusions: settings?.enforceExclusions ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -50,19 +57,25 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const intro = String(form.get("intro") ?? "").trim();
|
||||
const note = String(form.get("note") ?? "").trim();
|
||||
|
||||
const data = {
|
||||
emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null,
|
||||
emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null,
|
||||
emailNote: note || null,
|
||||
notifyEnabled: form.get("notifyEnabled") === "true",
|
||||
notifyEmail: String(form.get("notifyEmail") ?? "").trim() || null,
|
||||
tagEnabled: form.get("tagEnabled") === "true",
|
||||
enforceWindow: form.get("enforceWindow") === "true",
|
||||
defaultWindowDays: Math.min(
|
||||
365,
|
||||
Math.max(1, Number(form.get("windowDays")) || 14),
|
||||
),
|
||||
enforceExclusions: form.get("enforceExclusions") === "true",
|
||||
};
|
||||
|
||||
await db.settings.upsert({
|
||||
where: { shop: session.shop },
|
||||
create: {
|
||||
shop: session.shop,
|
||||
emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null,
|
||||
emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null,
|
||||
emailNote: note || null,
|
||||
},
|
||||
update: {
|
||||
emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null,
|
||||
emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null,
|
||||
emailNote: note || null,
|
||||
},
|
||||
create: { shop: session.shop, ...data },
|
||||
update: data,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
@@ -77,6 +90,14 @@ export default function SettingsPage() {
|
||||
const [subject, setSubject] = useState(data.subject);
|
||||
const [intro, setIntro] = useState(data.intro);
|
||||
const [note, setNote] = useState(data.note);
|
||||
const [notifyEnabled, setNotifyEnabled] = useState(data.notifyEnabled);
|
||||
const [notifyEmail, setNotifyEmail] = useState(data.notifyEmail);
|
||||
const [tagEnabled, setTagEnabled] = useState(data.tagEnabled);
|
||||
const [enforceWindow, setEnforceWindow] = useState(data.enforceWindow);
|
||||
const [windowDays, setWindowDays] = useState(String(data.windowDays));
|
||||
const [enforceExclusions, setEnforceExclusions] = useState(
|
||||
data.enforceExclusions,
|
||||
);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
|
||||
const saving = nav.state === "submitting";
|
||||
@@ -100,6 +121,12 @@ export default function SettingsPage() {
|
||||
fd.set("subject", subject);
|
||||
fd.set("intro", intro);
|
||||
fd.set("note", note);
|
||||
fd.set("notifyEnabled", String(notifyEnabled));
|
||||
fd.set("notifyEmail", notifyEmail);
|
||||
fd.set("tagEnabled", String(tagEnabled));
|
||||
fd.set("enforceWindow", String(enforceWindow));
|
||||
fd.set("windowDays", windowDays);
|
||||
fd.set("enforceExclusions", String(enforceExclusions));
|
||||
submit(fd, { method: "post" });
|
||||
};
|
||||
|
||||
@@ -173,6 +200,81 @@ export default function SettingsPage() {
|
||||
</BlockStack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<BlockStack gap="400">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Notifiche al merchant
|
||||
</Text>
|
||||
<Checkbox
|
||||
label="Invia email di notifica a ogni recesso"
|
||||
checked={notifyEnabled}
|
||||
onChange={setNotifyEnabled}
|
||||
/>
|
||||
<TextField
|
||||
label="Email notifiche"
|
||||
type="email"
|
||||
value={notifyEmail}
|
||||
onChange={setNotifyEmail}
|
||||
autoComplete="off"
|
||||
disabled={!notifyEnabled}
|
||||
helpText="Dove ricevere le notifiche. Senza indirizzo l'email non parte."
|
||||
placeholder="ordini@tuonegozio.it"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Aggiungi il tag 'Recesso' all'ordine"
|
||||
checked={tagEnabled}
|
||||
onChange={setTagEnabled}
|
||||
helpText="Rende l'ordine filtrabile nella lista ordini."
|
||||
/>
|
||||
<div>
|
||||
<Button variant="primary" loading={saving} onClick={handleSave}>
|
||||
Salva
|
||||
</Button>
|
||||
</div>
|
||||
</BlockStack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<BlockStack gap="400">
|
||||
<BlockStack gap="100">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Regole di recesso
|
||||
</Text>
|
||||
<Text as="p" tone="subdued">
|
||||
Attiva questi controlli solo dopo aver verificato i dati. Da
|
||||
spenti, il recesso è sempre accettato.
|
||||
</Text>
|
||||
</BlockStack>
|
||||
<Checkbox
|
||||
label="Blocca i recessi oltre il termine"
|
||||
checked={enforceWindow}
|
||||
onChange={setEnforceWindow}
|
||||
helpText="Calcolato dalla data di evasione (o d'ordine) + i giorni sotto."
|
||||
/>
|
||||
<TextField
|
||||
label="Giorni di recesso"
|
||||
type="number"
|
||||
value={windowDays}
|
||||
onChange={setWindowDays}
|
||||
autoComplete="off"
|
||||
min={1}
|
||||
max={365}
|
||||
disabled={!enforceWindow}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Blocca i prodotti esclusi (Art. 59)"
|
||||
checked={enforceExclusions}
|
||||
onChange={setEnforceExclusions}
|
||||
helpText="Usa le regole nella pagina Esclusioni. Blocca se l'intero ordine è escluso."
|
||||
/>
|
||||
<div>
|
||||
<Button variant="primary" loading={saving} onClick={handleSave}>
|
||||
Salva
|
||||
</Button>
|
||||
</div>
|
||||
</BlockStack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<BlockStack gap="200">
|
||||
<Text as="h2" variant="headingMd">
|
||||
|
||||
@@ -24,7 +24,9 @@ export default function App() {
|
||||
<Link to="/app" rel="home">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/app/withdrawals">Recessi</Link>
|
||||
<Link to="/app/settings">Impostazioni</Link>
|
||||
<Link to="/app/exclusions">Esclusioni</Link>
|
||||
</NavMenu>
|
||||
<Outlet />
|
||||
</AppProvider>
|
||||
|
||||
101
app/app/routes/app.withdrawals.tsx
Normal file
101
app/app/routes/app.withdrawals.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import {
|
||||
Page,
|
||||
Layout,
|
||||
Card,
|
||||
DataTable,
|
||||
Text,
|
||||
Badge,
|
||||
BlockStack,
|
||||
} from "@shopify/polaris";
|
||||
import { TitleBar } from "@shopify/app-bridge-react";
|
||||
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { formatTransmittedAt } from "../lib/recesso.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const items = await db.withdrawalRequest.findMany({
|
||||
where: { shop: session.shop },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
return {
|
||||
rows: items.map((w) => ({
|
||||
id: w.id,
|
||||
orderName: w.orderName ?? w.orderId,
|
||||
customerName: w.customerName,
|
||||
email: w.email,
|
||||
transmittedAt: formatTransmittedAt(w.transmittedAt),
|
||||
receiptSent: !!w.receiptSentAt,
|
||||
hasReturn: !!w.shopifyReturnId,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export default function WithdrawalsPage() {
|
||||
const { rows } = useLoaderData<typeof loader>();
|
||||
|
||||
const tableRows = rows.map((r) => [
|
||||
r.orderName,
|
||||
r.customerName,
|
||||
r.email,
|
||||
r.transmittedAt,
|
||||
r.receiptSent ? "Inviata" : "-",
|
||||
r.hasReturn ? "Sì" : "-",
|
||||
]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<TitleBar title="Recessi" />
|
||||
<Layout>
|
||||
<Layout.Section>
|
||||
<Card>
|
||||
{rows.length === 0 ? (
|
||||
<BlockStack gap="200">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Nessun recesso
|
||||
</Text>
|
||||
<Text as="p" tone="subdued">
|
||||
Le richieste di recesso trasmesse dai clienti compariranno qui,
|
||||
con la data e ora di trasmissione (registro ai fini di prova).
|
||||
</Text>
|
||||
</BlockStack>
|
||||
) : (
|
||||
<BlockStack gap="300">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Registro recessi ({rows.length})
|
||||
</Text>
|
||||
<DataTable
|
||||
columnContentTypes={[
|
||||
"text",
|
||||
"text",
|
||||
"text",
|
||||
"text",
|
||||
"text",
|
||||
"text",
|
||||
]}
|
||||
headings={[
|
||||
"Ordine",
|
||||
"Cliente",
|
||||
"Email",
|
||||
"Trasmesso",
|
||||
"Ricevuta",
|
||||
"Reso",
|
||||
]}
|
||||
rows={tableRows}
|
||||
/>
|
||||
<Badge tone="info">
|
||||
Il timestamp di trasmissione attesta il momento del recesso
|
||||
(art. 54-bis).
|
||||
</Badge>
|
||||
</BlockStack>
|
||||
)}
|
||||
</Card>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -17,16 +17,29 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { sendWithdrawalReceipt } from "../lib/mailer.server";
|
||||
import { ERROR, statementTemplate, successMessage } from "../lib/recesso.copy";
|
||||
import {
|
||||
sendMerchantNotification,
|
||||
sendWithdrawalReceipt,
|
||||
} from "../lib/mailer.server";
|
||||
import {
|
||||
ERROR,
|
||||
EXCLUSION_REASON,
|
||||
exclusionMessage,
|
||||
statementTemplate,
|
||||
successMessage,
|
||||
} from "../lib/recesso.copy";
|
||||
import {
|
||||
MVP_LOCALE,
|
||||
checkExclusions,
|
||||
checkRateLimit,
|
||||
clientIp,
|
||||
createShopifyReturn,
|
||||
formatTransmittedAt,
|
||||
getShopInfo,
|
||||
tagOrderRecesso,
|
||||
htmlResponse,
|
||||
isValidEmail,
|
||||
isWindowExpired,
|
||||
lookupOrder,
|
||||
renderStep1,
|
||||
renderStep2,
|
||||
@@ -34,6 +47,53 @@ import {
|
||||
renderStep4,
|
||||
sha256,
|
||||
} from "../lib/recesso.server";
|
||||
import type { MatchedOrder } from "../lib/recesso.server";
|
||||
|
||||
// A6: verifica finestra + esclusioni (rispetta i toggle nei Settings). Ritorna
|
||||
// il messaggio d'errore se il recesso va bloccato, altrimenti null.
|
||||
async function checkCompliance(
|
||||
shop: string,
|
||||
match: MatchedOrder,
|
||||
): Promise<string | null> {
|
||||
const settings = await db.settings
|
||||
.findUnique({ where: { shop } })
|
||||
.catch(() => null);
|
||||
if (!settings) return null;
|
||||
|
||||
if (settings.enforceWindow) {
|
||||
const windowDays = settings.defaultWindowDays ?? 14;
|
||||
if (isWindowExpired(match, windowDays, new Date())) {
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "withdrawal_window_closed",
|
||||
detail: match.orderName,
|
||||
},
|
||||
});
|
||||
return ERROR.windowClosed;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.enforceExclusions) {
|
||||
const rules = await db.exclusionRule.findMany({ where: { shop } });
|
||||
const excl = checkExclusions(match, rules);
|
||||
if (excl.fullyExcluded) {
|
||||
await db.auditLog.create({
|
||||
data: { shop, event: "withdrawal_excluded", detail: match.orderName },
|
||||
});
|
||||
const reasonText = excl.reasons
|
||||
.map(
|
||||
(r) =>
|
||||
(EXCLUSION_REASON as Record<string, string>)[r] ??
|
||||
"prodotto escluso dal diritto di recesso",
|
||||
)
|
||||
.join("; ");
|
||||
return exclusionMessage(reasonText);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /apps/recesso -> Step 1 (form di lookup).
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
@@ -123,9 +183,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(A6): qui andrà il check finestra 14 gg (deadline engine). Se la
|
||||
// finestra è scaduta -> mostrare ERROR.windowClosed e bloccare.
|
||||
// TODO(A6): qui andrà il check esclusioni Art. 59 per gli item dell'ordine.
|
||||
// A6: finestra 14gg + esclusioni Art. 59 (se abilitate nei Settings).
|
||||
const block = await checkCompliance(shop, match);
|
||||
if (block) {
|
||||
return htmlResponse(
|
||||
renderStep1({
|
||||
error: block,
|
||||
orderName: orderNameInput,
|
||||
email: emailInput,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return htmlResponse(
|
||||
renderStep2({
|
||||
@@ -235,7 +303,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch }));
|
||||
}
|
||||
|
||||
// TODO(A6): re-check finestra 14 gg + esclusioni Art. 59 prima di registrare.
|
||||
// A6: re-check finestra + esclusioni (anti-tamper) prima di registrare.
|
||||
const block = await checkCompliance(shop, match);
|
||||
if (block) {
|
||||
return htmlResponse(renderStep1({ error: block }));
|
||||
}
|
||||
|
||||
// transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"),
|
||||
// NON di ricezione. Salvato in UTC (Prisma DateTime).
|
||||
@@ -328,6 +400,88 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
console.error("[recesso] aggiornamento stato ricevuta fallito:", e);
|
||||
}
|
||||
|
||||
// Integrazione Resi Shopify: crea un reso nativo per gli ordini evasi
|
||||
// (best-effort; il recesso legale e' gia' registrato). Non evaso -> il
|
||||
// merchant gestisce annullo/rimborso.
|
||||
let returnStatus: "created" | "no_returnable" | "error" = "error";
|
||||
try {
|
||||
const ret = await createShopifyReturn(admin, match.orderId);
|
||||
returnStatus = ret.status;
|
||||
if (ret.status === "created") {
|
||||
await db.withdrawalRequest.update({
|
||||
where: { id: created.id },
|
||||
data: { shopifyReturnId: ret.returnId },
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_created",
|
||||
detail: match.orderName,
|
||||
},
|
||||
});
|
||||
} else if (ret.status === "no_returnable") {
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_skipped",
|
||||
detail: "ordine non evaso o nulla da rendere",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error("[recesso] returnCreate:", ret.error);
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_failed",
|
||||
detail: ret.error.slice(0, 200),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[recesso] integrazione reso fallita:", e);
|
||||
}
|
||||
|
||||
// Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders.
|
||||
if (settings?.tagEnabled) {
|
||||
try {
|
||||
const t = await tagOrderRecesso(admin, match.orderId);
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: t.ok ? "order_tagged" : "order_tag_failed",
|
||||
detail: t.ok ? match.orderName : (t.error ?? "").slice(0, 200),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[recesso] tag ordine fallito:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Notifica email al merchant (se abilitata e con indirizzo impostato).
|
||||
const notifyTo = settings?.notifyEmail?.trim();
|
||||
if (settings?.notifyEnabled && notifyTo) {
|
||||
try {
|
||||
const notif = await sendMerchantNotification({
|
||||
to: notifyTo,
|
||||
orderName: match.orderName,
|
||||
customerName,
|
||||
customerEmail: email,
|
||||
orderUrl: match.orderUrl,
|
||||
transmittedAt: transmittedLabel,
|
||||
returnStatus,
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
|
||||
detail: notif.ok ? match.orderName : notif.error.slice(0, 200),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[recesso] notifica merchant fallita:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const msg = successMessage(match.orderName, transmittedLabel, email);
|
||||
return htmlResponse(renderStep4(msg));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "WithdrawalRequest" ADD COLUMN "shopifyReturnId" TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Settings" ADD COLUMN "notifyEmail" TEXT,
|
||||
ADD COLUMN "notifyEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "tagEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Settings" ADD COLUMN "enforceExclusions" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "enforceWindow" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -52,6 +52,11 @@ model Settings {
|
||||
emailSubject String?
|
||||
emailIntro String?
|
||||
emailNote String?
|
||||
notifyEnabled Boolean @default(true)
|
||||
notifyEmail String?
|
||||
tagEnabled Boolean @default(true)
|
||||
enforceWindow Boolean @default(false)
|
||||
enforceExclusions Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -87,6 +92,7 @@ model WithdrawalRequest {
|
||||
status WithdrawalStatus @default(RECEIVED)
|
||||
receiptSentAt DateTime?
|
||||
computedDeadline DateTime?
|
||||
shopifyReturnId String? // GID del Reso Shopify creato (se ordine evaso)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ api_version = "2026-04"
|
||||
|
||||
[access_scopes]
|
||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||
scopes = "read_orders,read_products"
|
||||
scopes = "read_orders,read_products,write_returns,write_orders"
|
||||
optional_scopes = [ ]
|
||||
use_legacy_install_flow = false
|
||||
|
||||
|
||||
Reference in New Issue
Block a user