Compare commits
4 Commits
2c8c2a7429
...
a6a1900287
| Author | SHA1 | Date | |
|---|---|---|---|
| a6a1900287 | |||
| 945f1a6230 | |||
| 883bd82708 | |||
| 3968d34011 |
118
AUDIT-STATI-ORDINE.md
Normal file
118
AUDIT-STATI-ORDINE.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Audit - Recesso per stato dell'ordine vs normativa
|
||||
|
||||
Verifica dello strumento (app + Shopify) rispetto al Codice del Consumo, per stato
|
||||
dell'ordine (in elaborazione / spedito / consegnato / annullato). Obiettivo:
|
||||
mappare, per ogni stato, il trattamento legale corretto, cosa fa oggi lo strumento,
|
||||
e i gap con i fix prioritizzati.
|
||||
|
||||
Fonti normative (verificate 2026-07-07):
|
||||
- Art. 52 Cod. Consumo - decorrenza termine 14 gg.
|
||||
- Art. 56 - obblighi del professionista (rimborso).
|
||||
- Art. 57 - obblighi del consumatore (restituzione, spese).
|
||||
- Art. 54-bis - funzione/pulsante di recesso (D.Lgs 209/2025, in vigore 19/06/2026).
|
||||
|
||||
---
|
||||
|
||||
## 1. Principi legali (verificati)
|
||||
|
||||
1. **Il diritto nasce dalla conclusione del contratto.** Il consumatore puo' recedere
|
||||
anche PRIMA della spedizione/consegna. Non serve aver ricevuto il bene per recedere.
|
||||
2. **Decorrenza del termine di 14 giorni (Art. 52):**
|
||||
- Beni: dal giorno in cui il consumatore (o terzo da lui designato, diverso dal
|
||||
vettore) acquisisce il **possesso fisico** del bene = **CONSEGNA**. Non la spedizione.
|
||||
- Beni multipli in un solo ordine, consegnati separatamente: dal possesso
|
||||
dell'**ultimo** bene.
|
||||
- Servizi: dalla **conclusione del contratto**.
|
||||
- Se il professionista non fornisce l'informativa sul recesso: termine esteso fino
|
||||
a 12 mesi + 14 gg. (L'app FORNISCE l'informativa -> vale il termine ordinario.)
|
||||
3. **Rimborso (Art. 56):** entro **14 gg** da quando il professionista e' informato del
|
||||
recesso. Comprende le spese di consegna standard. Il professionista puo' **trattenere**
|
||||
il rimborso finche' non ha ricevuto i beni o finche' il consumatore non prova di
|
||||
averli rispediti (salvo offerta di ritiro). Stesso mezzo di pagamento.
|
||||
4. **Restituzione e spese (Art. 57):** il consumatore restituisce entro 14 gg dalla
|
||||
comunicazione. Sostiene il **costo diretto della restituzione SOLO SE** il
|
||||
professionista lo ha informato di tale onere; altrimenti lo sostiene il professionista.
|
||||
Il consumatore risponde solo della diminuzione di valore da manipolazione oltre il
|
||||
necessario; NON risponde se non e' stato informato del diritto di recesso.
|
||||
|
||||
---
|
||||
|
||||
## 2. Matrice: stato ordine x trattamento x comportamento attuale x gap
|
||||
|
||||
| Stato Shopify | Recesso ammesso? | Finestra 14gg | Gestione corretta | Cosa fa OGGI l'app | Gap |
|
||||
|---|---|---|---|---|---|
|
||||
| **Non evaso** (in elaborazione, pagato) | Si' | NON iniziata (nessun possesso) | Annullamento + rimborso pieno (incl. consegna). Nessun reso | Reso: skip (no_returnable). Tag + notifica. Finestra: calcola da data ordine | **G1** finestra puo' bloccare a torto; **G2** nessun annullo/rimborso |
|
||||
| **Spedito / in transito** (evaso, non consegnato) | Si' | NON iniziata o in decorrenza solo alla consegna | Rifiuto consegna o reso dopo ricezione | Reso creato (perche' "fulfilled"). Tag + notifica. Finestra: da data spedizione | **G3** reso forse prematuro; **G4** finestra da spedizione non da consegna |
|
||||
| **Consegnato** | Si' (entro finestra) | Decorre dalla **consegna** | Reso + rimborso | Reso creato. Tag + notifica. Finestra: da data spedizione | **G4** finestra ancorata a spedizione non a consegna |
|
||||
| **Parz. evaso** | Si' | Dall'ultimo bene consegnato | Reso parziale | Reso solo per righe evase. Finestra: da ultima evasione | **G4** + nuance parziale/esclusioni |
|
||||
| **Annullato / rimborsato** | Gia' chiuso | N/A | Nessuna azione (gia' risolto) | Processa comunque: crea record; reso puo' fallire | **G5** nessun rilevamento stato chiuso -> rischio doppio reso/rimborso |
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap dettagliati e fix
|
||||
|
||||
### G1 - Finestra blocca a torto ordini non consegnati [P1]
|
||||
`computeDeadline` usa `fulfilledAt || createdAt`. Su ordine NON evaso usa la data
|
||||
ordine: se enforceWindow attivo e l'ordine ha piu' di 14 gg ma non e' mai stato
|
||||
consegnato, il recesso viene bloccato -> **errato** (la finestra non e' nemmeno iniziata).
|
||||
**Fix:** se non c'e' consegna, la finestra NON e' iniziata -> non bloccare mai.
|
||||
|
||||
### G4 - Finestra ancorata a spedizione, non a consegna [P1]
|
||||
Art. 52 = possesso fisico (consegna). Oggi il riferimento e' `fulfillment.createdAt`
|
||||
(~ spedizione), che precede la consegna -> scadenza calcolata troppo presto ->
|
||||
rischio di bloccare recessi ancora validi.
|
||||
**Fix:** usare la data dell'evento di **consegna** (`displayFulfillmentStatus = DELIVERED`
|
||||
+ data evento di consegna dei fulfillment). Se spedito ma non consegnato -> finestra
|
||||
non iniziata. Fallback conservativo se il dato consegna manca: non bloccare.
|
||||
|
||||
### G5 - Nessun rilevamento di ordini annullati/rimborsati [P1]
|
||||
Su ordine gia' annullato/rimborsato l'app processa comunque (crea record, tenta reso).
|
||||
**Fix:** leggere `cancelledAt` / `displayFinancialStatus` (REFUNDED/VOIDED) e
|
||||
`returns` esistenti; se gia' chiuso -> registrare il recesso ma saltare reso e avvisare
|
||||
il merchant (niente doppio rimborso).
|
||||
|
||||
### G2 - Pre-spedizione: nessun annullo/rimborso automatico [P2]
|
||||
Legalmente pre-consegna e' un annullamento. Oggi: solo tag + notifica, merchant manuale.
|
||||
**Fix (opzionale):** opzione Settings "annulla/rimborsa in automatico gli ordini non
|
||||
evasi al recesso" (orderCancel/refundCreate). Richiede scope aggiuntivi -> valutare.
|
||||
|
||||
### G3 - Reso creato alla spedizione, non alla consegna [P2]
|
||||
Creiamo il reso appena l'ordine e' "fulfilled" (spedito), anche se non consegnato.
|
||||
Il flusso reso Shopify assume beni presso il consumatore.
|
||||
**Fix:** valutare se creare il reso solo a consegna avvenuta (DELIVERED), altrimenti
|
||||
solo tag + notifica finche' non consegnato.
|
||||
|
||||
### G6 - Informativa spese di restituzione (Art. 57) [P2]
|
||||
Il consumatore paga il reso SOLO se informato. Verificare che storefront/ricevuta
|
||||
lo dichiarino; altrimenti l'onere e' del merchant.
|
||||
**Fix:** riga informativa "le spese di restituzione sono a tuo carico" (configurabile:
|
||||
chi paga) nel modal e/o nella ricevuta. Coordinare con testo Art. 49 gia' presente.
|
||||
|
||||
### G7 - Rimborso: tempi e trattenuta (Art. 56) [P3]
|
||||
L'app non gestisce rimborsi (giusto lasciarli a Shopify/merchant). Ma il merchant va
|
||||
aiutato sui tempi (14 gg) e sulla facolta' di trattenere fino a riconsegna.
|
||||
**Fix:** nella notifica al merchant, ricordare "rimborso entro 14 gg; puoi trattenere
|
||||
fino a riconsegna dei beni o prova di spedizione". (Solo copy, no logica.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Priorita' consigliata
|
||||
|
||||
- **P1 (correttezza legale) - FATTO (2026-07-07):** G1 + G4 (finestra su data di
|
||||
CONSEGNA via evento `DELIVERED`; mai bloccare se non consegnato) + G5 (ordini
|
||||
annullati/rimborsati/voided -> skip reso). `lookupOrder` esteso con data consegna,
|
||||
`cancelledAt`, `displayFinancialStatus`.
|
||||
- **P2:** G6 (informativa spese reso) + G3 (reso a consegna) + G2 (annullo/rimborso
|
||||
pre-spedizione opzionale).
|
||||
- **P3:** G7 (copy rimborso nella notifica merchant).
|
||||
|
||||
---
|
||||
|
||||
## 5. Note
|
||||
|
||||
- Tutto A6 (finestra/esclusioni) e' oggi dietro toggle default OFF: i gap G1/G4 non
|
||||
sono attivi finche' il merchant non abilita l'enforcement. Comunque da correggere
|
||||
prima di consigliarne l'attivazione.
|
||||
- Servizi (non beni): decorrenza dalla conclusione. L'app oggi ragiona su beni/ordini
|
||||
fisici; per merchant di soli servizi la finestra andrebbe ancorata a `createdAt`
|
||||
(gia' fallback) - ok, ma da esplicitare se rilevante.
|
||||
43
CHECKLIST-COMPLIANCE-MERCHANT.md
Normal file
43
CHECKLIST-COMPLIANCE-MERCHANT.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Recesso - Cosa fa l'app vs cosa deve fare il merchant
|
||||
|
||||
Ripartizione delle responsabilita' di conformita' (Art. 54-bis + Codice del
|
||||
Consumo). L'app copre la FUNZIONE elettronica di recesso; alcuni obblighi restano
|
||||
in capo al merchant. Da consegnare col progetto.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cosa GARANTISCE l'app (automatico, non disattivabile)
|
||||
|
||||
- **Pulsante/funzione di recesso sempre accessibile** (footer o dove scelto), **guest**, senza login.
|
||||
- Raccolta della **dichiarazione inequivocabile** (nome, n. ordine, email, testo) con **conferma dedicata a 2 step** (nessun dark pattern).
|
||||
- Registrazione con **timestamp di TRASMISSIONE** (onere della prova, Art. 54-bis).
|
||||
- **Ricevuta su supporto durevole** al consumatore: dichiarazione + timestamp + avviso di legge - **sempre inviata, contenuto legale non modificabile** dal merchant.
|
||||
- **Audit log immutabile** delle richieste ed eventi.
|
||||
- Coesistenza col reso/rimborso **nativo Shopify** (crea il Reso per gli ordini evasi).
|
||||
|
||||
## 2. Cosa il merchant CONFIGURA nell'app (Impostazioni, opzionale)
|
||||
|
||||
- Testi email (oggetto/introduzione/nota) - le parti legali restano fisse.
|
||||
- Notifica al merchant (on/off + indirizzo email).
|
||||
- Tag "Recesso" sull'ordine (on/off).
|
||||
- Finestra 14 gg: enforcement on/off + giorni (default 14, calcolata sulla **consegna**).
|
||||
- Esclusioni Art. 59 (prodotti/tag non recedibili) + enforcement on/off.
|
||||
- **[R1 A6-bis]** Ricevuta differenziata per stato ordine; annullo automatico ordini non evasi; indirizzo reso + spese a carico cliente + istruzioni di reso.
|
||||
|
||||
## 3. Cosa il merchant deve fare FUORI dall'app (obblighi propri)
|
||||
|
||||
- **Informativa precontrattuale** sul diritto di recesso (Art. 49) nelle pagine/checkout.
|
||||
- Mettere a disposizione il **modulo tipo** di recesso (Allegato I, parte B) - coesiste col pulsante.
|
||||
- **Emettere il rimborso entro 14 gg** dalla notifica (Art. 56) se non usa l'annullo/rimborso automatico dell'app. Puo' trattenere fino a riconsegna merce o prova di spedizione.
|
||||
- Gestire **resi parziali** e rimborsi proporzionali (pannello ordini Shopify).
|
||||
- Definire la **politica di reso** (indirizzo, spese, integrita' prodotto) coerente con quanto mostra l'app.
|
||||
- **Verifica manuale della data di consegna** se il corriere non trasmette l'evento a Shopify (in tal caso la finestra automatica non blocca).
|
||||
- Privacy policy, condizioni di vendita, gestione dati (GDPR) del negozio.
|
||||
- Configurare le **esclusioni Art. 59** solo per prodotti realmente esclusi (mala-config = negare il diritto a torto).
|
||||
|
||||
## 4. Note
|
||||
|
||||
- L'app e' uno **strumento** di conformita', non sostituisce la consulenza legale.
|
||||
- Prima del go-live (e soprattutto per la versione pubblica/App Store) consigliata una **review legale**, anche per chiudere i punti ⚠ dottrinali in `ANALISI-REQUISITI-LEGALI.md`.
|
||||
- Ambito: **beni B2C online**. Servizi e beni digitali hanno decorrenza/esclusioni diverse (Art. 52/59) - da valutare per merchant fuori scope.
|
||||
- Approfondimento stati ordine: `AUDIT-STATI-ORDINE.md`.
|
||||
41
PLAN.md
41
PLAN.md
@@ -140,7 +140,7 @@ Corretta e integrata dopo `ANALISI-REQUISITI-LEGALI.md`.
|
||||
## 3. Modello dati (bozza Prisma)
|
||||
|
||||
- **Shop** — dominio, accessToken(cifrato), piano, installedAt
|
||||
- **Settings** — labelPulsante, brandColors, indirizzoReso, giorniFinestraDefault, overrideMercati, testoInfoRecesso
|
||||
- **Settings** — labelPulsante, brandColors, indirizzoReso, giorniFinestraDefault, overrideMercati, testoInfoRecesso, email (emailSubject/emailIntro/emailNote), + toggle per-shop: notifyEnabled/notifyEmail, tagEnabled, enforceWindow, enforceExclusions, **stateAwareEmail, autoCancelUnfulfilled, returnAtCustomerExpense, returnInstructions**
|
||||
- **ExclusionRule** — scope(prodotto|collezione|tag|tutto), targetId, motivo(sumisura|deperibile|igiene), attiva
|
||||
- **WithdrawalRequest** — shopId, orderId, orderName, nomeCliente, email, testoDichiarazione, **trasmessoAt (ts)**, locale, canale(guest|account), tipoProdotto, stato, ricevutaInviataAt, scadenzaCalcolata
|
||||
- **AuditLog** — shopId, evento, hashPayload, ts (append-only, immutabile)
|
||||
@@ -173,6 +173,14 @@ input / deliverable / criteri di uscita espliciti. **Nessun avvio automatico**
|
||||
3. **CSS custom** — escape hatch per personalizzazione totale.
|
||||
Merchant sceglie il livello; default = card a token controllata. Può diventare un agente dedicato (theming) se troppo grande per A5.
|
||||
- **A6 compliance-engine** — esclusioni Art. 59 + calcolo finestra/scadenza per tipo prodotto + warning misconfig + logica rimborso (R8,R9,R10,R13,R15). **Uscita:** merchant configura esclusioni/finestre; item escluso si comporta secondo R9.
|
||||
- **A6-bis operatività per stato ordine** *(confermata call Pizeta 2026-06-16 + `AUDIT-STATI-ORDINE.md`)* — comportamenti aggiuntivi, **tutti toggle per-shop** (app generica: ognuno decide). Dati già raccolti dal lookup: `displayFulfillmentStatus`, `deliveredAt`, `cancelledAt`, `financialStatus`.
|
||||
1. **Ricevuta differenziata per stato** (`stateAwareEmail`, default ON): la ricevuta durevole (sempre inviata) include un blocco operativo diverso —
|
||||
- *non evaso* → "ordine annullato, procederemo al rimborso";
|
||||
- *spedito/consegnato* → istruzioni di reso (indirizzo, spese a carico cliente, prodotto integro, rimborso dopo il rientro).
|
||||
2. **Auto-annullamento ordini non evasi** (`autoCancelUnfulfilled`, default OFF — irreversibile, opt-in): al recesso su ordine non evaso → `orderCancel` (refund+restock). Bonus: Shopify emette `orders/cancelled` → si ferma il bot di remarketing del merchant.
|
||||
3. **Istruzioni di reso configurabili**: `returnAddress` (già in schema), `returnAtCustomerExpense` (default ON — Art. 57: informa + rende il cliente responsabile delle spese), `returnInstructions` (testo opzionale).
|
||||
**Resta MANUALE per scelta Pizeta** (nessuna automazione richiesta): resi parziali + rimborso proporzionale (pannello Shopify), emissione rimborso (Shopify manda la sua mail), verifica data consegna se il corriere non passa l'evento a Shopify.
|
||||
**Uscita:** merchant attiva/disattiva ciascun comportamento; il flusso si adatta allo stato dell'ordine.
|
||||
|
||||
### Fase 4 — Robustezza *(A7→A8 sequenziali, poi A9 audit)*
|
||||
- **A7 i18n** — localizzazione IT/EN/DE/FR/ES (R14).
|
||||
@@ -191,6 +199,37 @@ A0 → A1 → A2 → A3 → A4 → A5 → A6 → A7 → A8 → A9 → A10 → A1
|
||||
|
||||
---
|
||||
|
||||
## 4-ter. Piano consolidato — stato attuale + residuo *(agg. 2026-07-07)*
|
||||
|
||||
Vista unica: cosa è FATTO e cosa RESTA, con A6-bis (Pizeta) e i gap dell'audit
|
||||
integrati. Dettaglio stato/commit nella memoria di progetto + git.
|
||||
|
||||
### ✅ Fatto (custom-grade, testato su pcrt-reso-test)
|
||||
- **A0–A1** fondamenta + spec compliance + copy deck.
|
||||
- **A2** Theme App Extension (app block + app embed) + **modal** storefront (iframe, redesign de-AI, popover info).
|
||||
- **A3** App Proxy: flusso guest 2-step + persistenza + **timestamp trasmissione** + audit (anti-enumeration).
|
||||
- **A4** ricevuta durevole (nodemailer/Mailpit dev) + **dati per-shop dinamici** (nome/URL/link ordine da Shopify) + **template editabile vincolato** (oggetto/intro/nota + anteprima live + ripristino).
|
||||
- **A5 (parziale)** admin Polaris: dashboard **Recessi**, **Impostazioni** (email/notifiche/regole), **Esclusioni** (CRUD). Theming: solo token base.
|
||||
- **A6 (parziale)** engine finestra + esclusioni Art. 59 (dietro toggle, default OFF).
|
||||
- **A8 (stub)** webhook GDPR + HMAC.
|
||||
- **EXTRA (oltre il piano originale):** integrazione **Resi Shopify** (`returnCreate` su ordini evasi), **notifica merchant + tag** (toggle), **fix P1** (finestra sulla CONSEGNA, skip ordini annullati/rimborsati), **avviso ordini chiusi**, **audit stati ordine** (`AUDIT-STATI-ORDINE.md`).
|
||||
|
||||
### ⏳ Residuo (prioritizzato)
|
||||
|
||||
- **R1 — A6-bis operatività per stato ordine (Pizeta-confirmed)** — 3 toggle per-shop: `stateAwareEmail` (ricevuta differenziata non-evaso/spedito-consegnato), `autoCancelUnfulfilled` (annullo automatico ordini non evasi), `returnAtCustomerExpense`+`returnInstructions`+`returnAddress` (istruzioni reso, Art. 57). Dettaglio: sezione A6-bis.
|
||||
- **R2 — A6 residuo copy compliance** — G7: promemoria rimborso 14gg + facoltà di trattenuta (Art. 56) nella notifica al merchant. *(G6 spese-reso assorbito da R1.)*
|
||||
- **R3 — Deploy custom (go-live)** — Fly.io + Postgres prod + URL stabile + tunnel Cloudflare nominato + link install custom sui 2-3 store live. *(Serve per usarlo davvero; occhio deadline transfer pizeta-pharma-2 ~metà luglio 2026.)*
|
||||
- **R4 — A9 hardening / QA-security** — rate-limit robusto, retry ricevuta fallita, idempotenza webhook, review avversariale (enumeration lookup, HMAC, PII), Protected Customer Data (approvazione Shopify per leggere `order.email` in prod).
|
||||
- **R5 — A7 i18n** — IT/EN/DE/FR/ES.
|
||||
- **R6 — A5 residuo theming** — motore 3 livelli completo (token no-code / eredita-tema Liquid / CSS custom).
|
||||
- **R7 — Pubblica** — A10 billing + A11 submission App Store (registrazione Partner separata → non impatta le custom live).
|
||||
|
||||
### Ordine consigliato
|
||||
`R1 → R2 → R3 (deploy) → R4 (hardening) → R5/R6 → R7`
|
||||
R1/R2 chiudono valore-cliente + compliance copy; R3 mette live; R4 mette in sicurezza prima del traffico reale; R5/R6 rifiniscono; R7 quando si va public.
|
||||
|
||||
---
|
||||
|
||||
## 5. Milestone
|
||||
|
||||
- **M1 (Fase 0–1):** scaffold + contratto compliance. *Nulla di visibile, ma toglie rischio a tutto.*
|
||||
|
||||
@@ -61,11 +61,45 @@ export function renderSubject(subjectTpl: string | null | undefined, vars: Email
|
||||
return substPlain(tpl, vars).trim() || substPlain(DEFAULT_SUBJECT, vars);
|
||||
}
|
||||
|
||||
export interface OperationalConfig {
|
||||
state: "unfulfilled" | "shipped" | "delivered";
|
||||
returnAddress?: string | null;
|
||||
atCustomerExpense: boolean;
|
||||
instructions?: string | null;
|
||||
}
|
||||
|
||||
/** Blocco operativo per stato ordine (A6-bis): annullo (non evaso) o istruzioni reso. */
|
||||
function renderOperationalBlock(op: OperationalConfig): string {
|
||||
let inner: string;
|
||||
if (op.state === "unfulfilled") {
|
||||
inner =
|
||||
"Il tuo ordine non risultava ancora spedito: procederemo all'annullamento e al rimborso. Non devi restituire nulla.";
|
||||
} else {
|
||||
const stateWord = op.state === "delivered" ? "consegnato" : "spedito";
|
||||
const addr =
|
||||
op.returnAddress && op.returnAddress.trim()
|
||||
? `<strong>${escHtml(op.returnAddress.trim())}</strong>`
|
||||
: "l'indirizzo che ti comunicheremo";
|
||||
const spese = op.atCustomerExpense
|
||||
? "Le spese di restituzione sono a tuo carico."
|
||||
: "Le spese di restituzione sono a nostro carico.";
|
||||
const instr =
|
||||
op.instructions && op.instructions.trim()
|
||||
? `<br>${nl2br(escHtml(op.instructions.trim()))}`
|
||||
: "";
|
||||
inner = `Il prodotto risulta ${stateWord}. Per ottenere il rimborso, restituisci la merce integra a: ${addr}. ${spese}${instr}<br>Il rimborso sara' disposto dopo il rientro della merce.`;
|
||||
}
|
||||
return `<tr><td style="padding:4px 32px 8px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="padding:14px 16px;background:#fff7e6;border:1px solid #ffe2a8;border-radius:8px;font-size:14px;line-height:1.6;color:#6a5518;">${inner}</td></tr></table>
|
||||
</td></tr>`;
|
||||
}
|
||||
|
||||
/** Corpo HTML fisso con intro/nota editabili inseriti. */
|
||||
export function renderReceiptHtml(
|
||||
vars: EmailVars,
|
||||
introTpl: string | null | undefined,
|
||||
noteTpl: string | null | undefined,
|
||||
operational?: OperationalConfig | null,
|
||||
): string {
|
||||
const intro = richText((introTpl && introTpl.trim()) || DEFAULT_INTRO, vars);
|
||||
const noteVal = noteTpl && noteTpl.trim() ? richText(noteTpl, vars) : "";
|
||||
@@ -107,6 +141,7 @@ export function renderReceiptHtml(
|
||||
<tr><td style="padding:12px 32px 4px;">
|
||||
<p style="margin:0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Questa comunicazione costituisce la ricevuta su supporto durevole ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora indicate attestano il momento della trasmissione.</p>
|
||||
</td></tr>
|
||||
${operational ? renderOperationalBlock(operational) : ""}
|
||||
${note}
|
||||
<tr><td style="padding:18px 32px 24px;border-top:1px solid #ececec;">
|
||||
<p style="margin:0 0 8px;font-size:12.5px;line-height:1.6;color:#999;">Ti invieremo separatamente le istruzioni per l'eventuale reso e i tempi di rimborso.</p>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
renderReceiptHtml,
|
||||
renderSubject,
|
||||
type EmailVars,
|
||||
type OperationalConfig,
|
||||
} from "./emailTemplate";
|
||||
|
||||
function buildTransport() {
|
||||
@@ -65,6 +66,7 @@ export async function sendWithdrawalReceipt(params: {
|
||||
subject?: string | null;
|
||||
intro?: string | null;
|
||||
note?: string | null;
|
||||
operational?: OperationalConfig | null;
|
||||
}): Promise<ReceiptResult> {
|
||||
const transport = buildTransport();
|
||||
if (!transport) {
|
||||
@@ -72,7 +74,12 @@ export async function sendWithdrawalReceipt(params: {
|
||||
}
|
||||
|
||||
const subject = renderSubject(params.subject, params.vars);
|
||||
const html = renderReceiptHtml(params.vars, params.intro, params.note);
|
||||
const html = renderReceiptHtml(
|
||||
params.vars,
|
||||
params.intro,
|
||||
params.note,
|
||||
params.operational,
|
||||
);
|
||||
const text = htmlToText(html);
|
||||
|
||||
try {
|
||||
|
||||
@@ -47,6 +47,12 @@ export const ERROR = {
|
||||
generic: "Si è verificato un problema. Riprova tra poco.",
|
||||
} as const;
|
||||
|
||||
// Avvisi informativi (non bloccanti).
|
||||
export const NOTICE = {
|
||||
orderClosed:
|
||||
"Questo ordine risulta già annullato o rimborsato. Puoi comunque registrare il recesso; il negozio ti contatterà per eventuali dettagli.",
|
||||
} as const;
|
||||
|
||||
// Schermata finale (dopo "Conferma recesso").
|
||||
export function successMessage(
|
||||
orderName: string,
|
||||
|
||||
@@ -123,22 +123,25 @@ 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
|
||||
fulfilledAt: string | null; // data ultima evasione (spedizione), null se non evaso
|
||||
deliveredAt: string | null; // data di consegna (possesso fisico, Art. 52), null se non consegnato
|
||||
cancelledAt: string | null; // data annullamento ordine, null altrimenti
|
||||
financialStatus: string | null; // displayFinancialStatus (REFUNDED/VOIDED/PAID/...)
|
||||
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.
|
||||
// Riferimento = data di CONSEGNA (possesso fisico, Art. 52), NON la spedizione.
|
||||
// Se non consegnato -> la finestra non e' iniziata -> nessuna scadenza (non si
|
||||
// blocca mai): il recesso resta ammesso (nasce dalla conclusione del contratto).
|
||||
// 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 (!match.deliveredAt) return null;
|
||||
const d = new Date(match.deliveredAt);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setUTCDate(d.getUTCDate() + windowDays);
|
||||
return d;
|
||||
@@ -200,6 +203,23 @@ export function checkExclusions(
|
||||
};
|
||||
}
|
||||
|
||||
// --- A6-bis: stato operativo ordine --------------------------------------
|
||||
export type OrderState = "unfulfilled" | "shipped" | "delivered" | "closed";
|
||||
|
||||
/** Stato per il flusso recesso (email differenziata + auto-annullo). */
|
||||
export function orderState(match: MatchedOrder): OrderState {
|
||||
if (
|
||||
match.cancelledAt ||
|
||||
match.financialStatus === "REFUNDED" ||
|
||||
match.financialStatus === "VOIDED"
|
||||
) {
|
||||
return "closed";
|
||||
}
|
||||
if (match.deliveredAt) return "delivered";
|
||||
if (match.fulfilledAt) return "shipped";
|
||||
return "unfulfilled";
|
||||
}
|
||||
|
||||
interface OrderLookupGraphQL {
|
||||
data?: {
|
||||
orders?: {
|
||||
@@ -210,8 +230,20 @@ interface OrderLookupGraphQL {
|
||||
email?: string | null;
|
||||
createdAt?: string | null;
|
||||
statusPageUrl?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
displayFulfillmentStatus?: string | null;
|
||||
fulfillments?: Array<{ createdAt?: string | null } | null> | null;
|
||||
displayFinancialStatus?: string | null;
|
||||
fulfillments?: Array<{
|
||||
createdAt?: string | null;
|
||||
events?: {
|
||||
edges?: Array<{
|
||||
node?: {
|
||||
status?: string | null;
|
||||
happenedAt?: string | null;
|
||||
} | null;
|
||||
} | null> | null;
|
||||
} | null;
|
||||
} | null> | null;
|
||||
lineItems?: {
|
||||
edges?: Array<{
|
||||
node?: {
|
||||
@@ -236,9 +268,19 @@ const ORDER_LOOKUP_QUERY = `#graphql
|
||||
email
|
||||
createdAt
|
||||
statusPageUrl
|
||||
cancelledAt
|
||||
displayFulfillmentStatus
|
||||
displayFinancialStatus
|
||||
fulfillments(first: 10) {
|
||||
createdAt
|
||||
events(first: 25) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
happenedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lineItems(first: 50) {
|
||||
edges {
|
||||
@@ -468,6 +510,51 @@ export async function tagOrderRecesso(
|
||||
}
|
||||
}
|
||||
|
||||
const ORDER_CANCEL_MUTATION = `#graphql
|
||||
mutation recessoOrderCancel($id: ID!) {
|
||||
orderCancel(orderId: $id, reason: CUSTOMER, refund: true, restock: true, notifyCustomer: false, staffNote: "Recesso art. 54-bis") {
|
||||
job { id }
|
||||
orderCancelUserErrors { field message }
|
||||
}
|
||||
}`;
|
||||
|
||||
interface OrderCancelGraphQL {
|
||||
data?: {
|
||||
orderCancel?: {
|
||||
orderCancelUserErrors?: Array<{ message?: string | null }> | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Annulla l'ordine (recesso su ordine non evaso): refund + restock. Best-effort. */
|
||||
export async function cancelOrder(
|
||||
admin: AdminApiContext,
|
||||
orderGid: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await admin.graphql(ORDER_CANCEL_MUTATION, {
|
||||
variables: { id: orderGid },
|
||||
});
|
||||
const body = (await res.json()) as OrderCancelGraphQL;
|
||||
const errs = body.data?.orderCancel?.orderCancelUserErrors ?? [];
|
||||
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 : "orderCancel fallito",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupOrder(
|
||||
admin: AdminApiContext,
|
||||
orderInput: string,
|
||||
@@ -503,6 +590,13 @@ export async function lookupOrder(
|
||||
.map((f) => f?.createdAt)
|
||||
.filter((d): d is string => !!d)
|
||||
.sort();
|
||||
const deliveryDates = (node.fulfillments ?? [])
|
||||
.flatMap((f) => f?.events?.edges ?? [])
|
||||
.map((e) => e?.node)
|
||||
.filter((n): n is NonNullable<typeof n> => !!n)
|
||||
.filter((n) => n.status === "DELIVERED" && !!n.happenedAt)
|
||||
.map((n) => n.happenedAt as string)
|
||||
.sort();
|
||||
const lineItems = (node.lineItems?.edges ?? [])
|
||||
.map((e) => e?.node)
|
||||
.filter((n): n is NonNullable<typeof n> => !!n)
|
||||
@@ -519,6 +613,11 @@ export async function lookupOrder(
|
||||
fulfilledAt: fulfillmentDates.length
|
||||
? fulfillmentDates[fulfillmentDates.length - 1]
|
||||
: null,
|
||||
deliveredAt: deliveryDates.length
|
||||
? deliveryDates[deliveryDates.length - 1]
|
||||
: null,
|
||||
cancelledAt: node.cancelledAt ?? null,
|
||||
financialStatus: node.displayFinancialStatus ?? null,
|
||||
lineItems,
|
||||
};
|
||||
}
|
||||
@@ -769,6 +868,21 @@ const PAGE_CSS = `
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.error__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: currentColor; }
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: rgba(240, 170, 40, 0.14);
|
||||
border: 1px solid rgba(240, 170, 40, 0.55);
|
||||
border-left: 3px solid rgba(240, 170, 40, 0.95);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 18px;
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.notice__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: #e0a020; }
|
||||
|
||||
/* Riepilogo (step 3) */
|
||||
.summary { margin: 18px 0 4px; }
|
||||
@@ -856,6 +970,14 @@ function errorBanner(message?: string): string {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function noticeBanner(message?: string): string {
|
||||
if (!message) return "";
|
||||
return `<div class="notice" role="status">
|
||||
<svg class="notice__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 5h2v2H9V5Zm0 4h2v6H9V9Z"/></svg>
|
||||
<span>${escapeHtml(message)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulsante info "i" + popover nativo (mini-modal, zero JS via Popover API).
|
||||
* Il popover vive nel top-layer; chiusura con Esc, click fuori o pulsante.
|
||||
@@ -948,12 +1070,13 @@ export function renderStep2(data: {
|
||||
customerName?: string;
|
||||
statementText: string;
|
||||
error?: string;
|
||||
notice?: string;
|
||||
}): string {
|
||||
const customerName = data.customerName ?? "";
|
||||
return renderShell(
|
||||
stepLayout({
|
||||
head: stepHead(2, `Ordine ${data.orderName}`),
|
||||
body: `${errorBanner(data.error)}
|
||||
body: `${errorBanner(data.error)}${noticeBanner(data.notice)}
|
||||
<form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
|
||||
<input type="hidden" name="intent" value="details">
|
||||
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
||||
|
||||
@@ -47,6 +47,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
enforceWindow: settings?.enforceWindow ?? false,
|
||||
windowDays: settings?.defaultWindowDays ?? 14,
|
||||
enforceExclusions: settings?.enforceExclusions ?? false,
|
||||
stateAwareEmail: settings?.stateAwareEmail ?? true,
|
||||
autoCancelUnfulfilled: settings?.autoCancelUnfulfilled ?? false,
|
||||
returnAtCustomerExpense: settings?.returnAtCustomerExpense ?? true,
|
||||
returnAddress: settings?.returnAddress ?? "",
|
||||
returnInstructions: settings?.returnInstructions ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -70,6 +75,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
Math.max(1, Number(form.get("windowDays")) || 14),
|
||||
),
|
||||
enforceExclusions: form.get("enforceExclusions") === "true",
|
||||
stateAwareEmail: form.get("stateAwareEmail") === "true",
|
||||
autoCancelUnfulfilled: form.get("autoCancelUnfulfilled") === "true",
|
||||
returnAtCustomerExpense: form.get("returnAtCustomerExpense") === "true",
|
||||
returnAddress: String(form.get("returnAddress") ?? "").trim() || null,
|
||||
returnInstructions:
|
||||
String(form.get("returnInstructions") ?? "").trim() || null,
|
||||
};
|
||||
|
||||
await db.settings.upsert({
|
||||
@@ -98,6 +109,17 @@ export default function SettingsPage() {
|
||||
const [enforceExclusions, setEnforceExclusions] = useState(
|
||||
data.enforceExclusions,
|
||||
);
|
||||
const [stateAwareEmail, setStateAwareEmail] = useState(data.stateAwareEmail);
|
||||
const [autoCancelUnfulfilled, setAutoCancelUnfulfilled] = useState(
|
||||
data.autoCancelUnfulfilled,
|
||||
);
|
||||
const [returnAtCustomerExpense, setReturnAtCustomerExpense] = useState(
|
||||
data.returnAtCustomerExpense,
|
||||
);
|
||||
const [returnAddress, setReturnAddress] = useState(data.returnAddress);
|
||||
const [returnInstructions, setReturnInstructions] = useState(
|
||||
data.returnInstructions,
|
||||
);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
|
||||
const saving = nav.state === "submitting";
|
||||
@@ -127,6 +149,11 @@ export default function SettingsPage() {
|
||||
fd.set("enforceWindow", String(enforceWindow));
|
||||
fd.set("windowDays", windowDays);
|
||||
fd.set("enforceExclusions", String(enforceExclusions));
|
||||
fd.set("stateAwareEmail", String(stateAwareEmail));
|
||||
fd.set("autoCancelUnfulfilled", String(autoCancelUnfulfilled));
|
||||
fd.set("returnAtCustomerExpense", String(returnAtCustomerExpense));
|
||||
fd.set("returnAddress", returnAddress);
|
||||
fd.set("returnInstructions", returnInstructions);
|
||||
submit(fd, { method: "post" });
|
||||
};
|
||||
|
||||
@@ -275,6 +302,52 @@ export default function SettingsPage() {
|
||||
</BlockStack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<BlockStack gap="400">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Reso e stato ordine
|
||||
</Text>
|
||||
<Checkbox
|
||||
label="Adatta la ricevuta allo stato dell'ordine"
|
||||
checked={stateAwareEmail}
|
||||
onChange={setStateAwareEmail}
|
||||
helpText="Non evaso → annullo+rimborso; spedito/consegnato → istruzioni di reso."
|
||||
/>
|
||||
<Checkbox
|
||||
label="Annulla automaticamente gli ordini non ancora spediti"
|
||||
checked={autoCancelUnfulfilled}
|
||||
onChange={setAutoCancelUnfulfilled}
|
||||
helpText="Al recesso, se l'ordine non è evaso: annullo + rimborso automatici. Irreversibile."
|
||||
/>
|
||||
<Checkbox
|
||||
label="Spese di restituzione a carico del cliente (Art. 57)"
|
||||
checked={returnAtCustomerExpense}
|
||||
onChange={setReturnAtCustomerExpense}
|
||||
/>
|
||||
<TextField
|
||||
label="Indirizzo per il reso"
|
||||
value={returnAddress}
|
||||
onChange={setReturnAddress}
|
||||
autoComplete="off"
|
||||
multiline={2}
|
||||
placeholder="Via ..., CAP Città (PR)"
|
||||
/>
|
||||
<TextField
|
||||
label="Istruzioni di reso (opzionale)"
|
||||
value={returnInstructions}
|
||||
onChange={setReturnInstructions}
|
||||
autoComplete="off"
|
||||
multiline={3}
|
||||
helpText="Testo aggiuntivo mostrato nella ricevuta per ordini spediti/consegnati."
|
||||
/>
|
||||
<div>
|
||||
<Button variant="primary" loading={saving} onClick={handleSave}>
|
||||
Salva
|
||||
</Button>
|
||||
</div>
|
||||
</BlockStack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<BlockStack gap="200">
|
||||
<Text as="h2" variant="headingMd">
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
ERROR,
|
||||
EXCLUSION_REASON,
|
||||
NOTICE,
|
||||
exclusionMessage,
|
||||
statementTemplate,
|
||||
successMessage,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
MVP_LOCALE,
|
||||
checkExclusions,
|
||||
checkRateLimit,
|
||||
cancelOrder,
|
||||
clientIp,
|
||||
createShopifyReturn,
|
||||
formatTransmittedAt,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
isValidEmail,
|
||||
isWindowExpired,
|
||||
lookupOrder,
|
||||
orderState,
|
||||
renderStep1,
|
||||
renderStep2,
|
||||
renderStep3,
|
||||
@@ -195,12 +198,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
);
|
||||
}
|
||||
|
||||
const orderClosed =
|
||||
!!match.cancelledAt ||
|
||||
match.financialStatus === "REFUNDED" ||
|
||||
match.financialStatus === "VOIDED";
|
||||
return htmlResponse(
|
||||
renderStep2({
|
||||
orderId: match.orderId,
|
||||
orderName: match.orderName,
|
||||
email: match.email,
|
||||
statementText: statementTemplate(match.orderName),
|
||||
notice: orderClosed ? NOTICE.orderClosed : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -360,6 +368,18 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
getShopInfo(admin),
|
||||
]);
|
||||
const shopName = shopInfo.name || shop.replace(/\.myshopify\.com$/, "");
|
||||
// A6-bis: blocco operativo per stato ordine (se stateAwareEmail attivo).
|
||||
const state = orderState(match);
|
||||
const operational =
|
||||
settings?.stateAwareEmail !== false &&
|
||||
(state === "unfulfilled" || state === "shipped" || state === "delivered")
|
||||
? {
|
||||
state,
|
||||
returnAddress: settings?.returnAddress,
|
||||
atCustomerExpense: settings?.returnAtCustomerExpense ?? true,
|
||||
instructions: settings?.returnInstructions,
|
||||
}
|
||||
: null;
|
||||
const receipt = await sendWithdrawalReceipt({
|
||||
to: email,
|
||||
vars: {
|
||||
@@ -374,6 +394,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
subject: settings?.emailSubject,
|
||||
intro: settings?.emailIntro,
|
||||
note: settings?.emailNote,
|
||||
operational,
|
||||
});
|
||||
try {
|
||||
if (receipt.ok) {
|
||||
@@ -401,44 +422,76 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
// Integrazione Resi Shopify: crea un reso nativo per gli ordini evasi
|
||||
// (best-effort; il recesso legale e' gia' registrato). Non evaso -> il
|
||||
// (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";
|
||||
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),
|
||||
},
|
||||
});
|
||||
const orderClosed =
|
||||
!!match.cancelledAt ||
|
||||
match.financialStatus === "REFUNDED" ||
|
||||
match.financialStatus === "VOIDED";
|
||||
if (orderClosed) {
|
||||
returnStatus = "no_returnable";
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_skipped",
|
||||
detail: "ordine annullato o rimborsato",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// A6-bis: auto-annullo ordini non evasi (se abilitato). refund + restock.
|
||||
if (settings?.autoCancelUnfulfilled && state === "unfulfilled") {
|
||||
try {
|
||||
const c = await cancelOrder(admin, match.orderId);
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: c.ok ? "order_auto_cancelled" : "order_auto_cancel_failed",
|
||||
detail: c.ok ? match.orderName : (c.error ?? "").slice(0, 200),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[recesso] auto-annullo fallito:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[recesso] integrazione reso fallita:", e);
|
||||
}
|
||||
|
||||
// Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Settings" ADD COLUMN "autoCancelUnfulfilled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "returnAtCustomerExpense" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "returnInstructions" TEXT,
|
||||
ADD COLUMN "stateAwareEmail" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -57,6 +57,10 @@ model Settings {
|
||||
tagEnabled Boolean @default(true)
|
||||
enforceWindow Boolean @default(false)
|
||||
enforceExclusions Boolean @default(false)
|
||||
stateAwareEmail Boolean @default(true)
|
||||
autoCancelUnfulfilled Boolean @default(false)
|
||||
returnAtCustomerExpense Boolean @default(true)
|
||||
returnInstructions String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
Reference in New Issue
Block a user