# Invoice Lifecycle & Controls — Canonical Design

> **Status**: APPROVED — single source of truth del lifecycle fattura passiva (sostituisce `docs/USER_GUIDE.md §5`). Driver `STATUS_ORDER` canonical in [`srv/process/statusTransitions.ts`](../srv/process/statusTransitions.ts); 8 mappe duplicate tracciate in [`docs/audit/2026-04-25-lifecycle-drift-matrix.md`](audit/2026-04-25-lifecycle-drift-matrix.md) per consolidamento (C8 followup 2026-04-28+).
> **Owner**: Architecture
> **Versione**: 0.2 (2026-04-28) — aggiunto: phase gates NON_PO + TD04, exception auto-dispatch a 4 OverridePolicy (AUTO_RESOLVE / SELF_SIGNED / REQUIRE_APPROVAL / NO_OVERRIDE), TOUCHLESS step idempotency CAS marker.
> **Modello di riferimento**: SAP VIM standard, specializzato per FatturaPA italiana

## §1 — Modello di riferimento

### 1.1 Baseline VIM standard

NOVA Invoice Suite adotta come **baseline normativo** il modello SAP VIM (Vendor Invoice Management). Il flusso canonico VIM è:

```mermaid
flowchart LR
    R[Receive] --> VR[Vendor<br/>Resolution]
    VR --> BC[Block<br/>Check]
    BC --> COD[Coding]
    COD --> APP[Approval]
    APP --> POS[Posting]
    POS --> ARC[Archiving]
```

#### Mappatura 1:1 VIM standard → fasi NOVA

| VIM standard                        | NOVA Phase ID         | NOVA label IT             |
| ----------------------------------- | --------------------- | ------------------------- |
| Receive                             | `F1_RECEPTION`        | Ricezione                 |
| Vendor Resolution                   | `F2_BP_RESOLUTION`    | Risoluzione Fornitore     |
| Block Check (Dup + Fiscal + Risk)   | `F3_VERIFICATION`     | Verifiche di Conformità   |
| Coding (PO match OR cost alloc.)    | `F4_MATCHING` *       | Abbinamento / Imputazione |
| Approval                            | `F5_APPROVAL`         | Approvazione              |
| Posting + Archiving                 | `F6_POSTING`          | Registrazione             |

\* `F4_MATCHING` ha 2 sotto-branch mutuamente esclusivi: `F4_PO` (3-way match) e `F4_NON_PO` (cost allocation manuale).

---

### 1.2 Specializzazioni vs VIM standard

| #  | Specializzazione                                                  | Motivazione                                                                                                                                                                                                                                  |
| -- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| S1 | F2 come fase autonoma (VIM lo ingloba in Receive)                 | FatturaPA richiede lookup esplicito S/4 su TaxCode + VatId + name-fuzzy + IBAN validation. Lo step ha confidence threshold dedicato (`BP_LOW_CONFIDENCE` tier) e può richiedere intervento manuale. Non assimilabile a parsing XML.          |
| S2 | F3 unifica DupCheck + Fiscal + Risk                               | Le tre verifiche sono read-only su dati già acquisiti, condividono la stessa transazione, nessuna richiede UI dedicata. Sotto-status interni preservano l'audit (non vengono fusi).                                                          |
| S3 | F4 si **biforca** in PO vs NON_PO                                 | VIM ha "Coding" sempre presente. In S/4HANA Public Cloud per fatture PO il coding deriva dal PO (3-way match), per NON_PO l'utente assegna GL/CC/IO/WBS manualmente. UI tab distinti + set di controlli diversi.                             |
| S4 | F3-bis (deviazione TD04 credit-note) tra F3 e F4                  | Vincolo FatturaPA: il tipo documento `TD04` (nota di credito) richiede match alla fattura originale prima di entrare in F4. Non in VIM standard.                                                                                             |
| S5 | F6 unifica Posting e Archiving                                    | In NOVA il Posting è verso S/4 (`API_SUPPLIER_INVOICE_PROCESS_SRV`), l'Archiving è Conservatore italiano a norma. Sono due step ma una sola fase per UX (post-approval terminal flow). `PARKED` è ramo intermedio dentro F6.                  |
| S6 | REWORK loop come ramo F5 → F3 (non F5 → F1)                       | Reject in approvazione invalida le verifiche, non l'acquisizione. Mod a BP/PO da operatore in F3+ scatena `rollbackToPhase` (cascade reset più profondo, decisione esplicita admin).                                                         |

---

### 1.3 Glossario chiave

| Termine               | Definizione                                                                                                                                                                                                              |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Phase**             | Raggruppamento di status con entry/exit, sotto-status, set di step `ProcessTemplate`, gates di uscita, mapping UI. **6 fasi** `F1..F6` + 1 deviazione `F3_BIS_CREDIT`.                                                  |
| **Status**            | Granularità DB-level dello stato fattura (`ProcessingStatus`). Ogni status appartiene a 1 sola fase. `STATUS_ORDER` in [`srv/process/statusTransitions.ts`](../srv/process/statusTransitions.ts) è l'ordering canonico. |
| **ProcessStep**       | Unità di esecuzione registrata in `ProcessStepExecution`. Ha `BoundAction` (handler), `ProducedStatus`, `Category` (`ENRICHMENT`/`VALIDATION`/`MATCHING`/`WORKFLOW`/`POSTING`).                                          |
| **ProcessStepCheck**  | Controllo eseguito *durante* uno step. Ha `Severity` (`ERROR`/`WARNING`/`INFO`), `OverridePolicy` (`AUTO_RESOLVE`/`SELF_SIGNED`/`REQUIRE_APPROVAL`/`NO_OVERRIDE`), `JustificationRequired` (Boolean).                    |
| **Phase gate**        | Controllo raised al **boundary** di transizione fase, **non** durante uno step. Es: `COST_ALLOCATION_REQUIRED` (entrando F4_NON_PO → F5), `CREDIT_NOTE_MATCH_MISSING` (entrando F3_BIS).                                |
| **Branch (deviazione)** | Percorso condizionale alternativo. Innescato da attributo invoice (`InvoiceCategory`, `SDIDocumentType`).                                                                                                              |

---

### 1.4 Forma del flusso

```mermaid
flowchart LR
    A["<b>F1 Ricezione</b><br/>NEW"] -->|confirmPhase| B["<b>F2 Risoluzione BP</b><br/>BP_RESOLVED"]
    B -->|BP_RESOLUTION + checks| C["<b>F3 Verifiche</b><br/>DUPLICATE_CHECK<br/>VALIDATED · RISK_ASSESSED"]
    C -->|category=PO| D["<b>F4-PO Matching</b><br/>MATCHING_PENDING<br/>PO_MATCHED · THREE_WAY_OK"]
    C -->|category=NON_PO| E["<b>F4-NON_PO Coding</b><br/>COST_ALLOCATION_PENDING<br/>COST_ALLOCATED · COST_VALIDATED"]
    C -.->|"SDIDocumentType=TD04"| F["<b>F3-bis Credit Match</b><br/>CREDIT_MATCHING<br/>CREDIT_MATCHED"]
    F --> D
    F --> E
    D --> G["<b>F5 Approvazione</b><br/>PENDING_APPROVAL<br/>REWORK loop · APPROVED"]
    E --> G
    G -->|reject| C
    G --> H["<b>F6 Registrazione</b><br/>POSTING_PENDING<br/>POSTED · PARKED<br/>ARCHIVED · REVERSED"]
    H -.->|terminale| Term["REJECTED (reason code)"]

    classDef happyPath fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20;
    classDef branch    fill:#fff3e0,stroke:#ef6c00,color:#e65100;
    classDef terminal  fill:#ffebee,stroke:#c62828,color:#b71c1c;
    class A,B,C,D,E,G,H happyPath;
    class F branch;
    class Term terminal;
```

> **Legenda colori**: 🟢 happy path (F1 → F6), 🟠 branch deviazione (F3-bis TD04), 🔴 stato terminale di rifiuto (con `RejectReasonCode`).
>
> **State-machine update 2026-04-28** (architectural decisions):
> - `NEW` è l'**unico stato iniziale runtime**. `RECEIVED`/`XML_PARSED` sono enum legacy
>   conservati solo per dati storici (vedi migrazione `db/migrations/{pg,hana}/009_invoice_status_dedupe.sql`).
> - **Non esiste più `CANCELLED` come stato lifecycle**. Ogni chiusura pre-S/4 va in
>   `REJECTED` con un `RejectReasonCode` (rifiuto business, annullamento operativo,
>   duplicato, non-processabile). Migrazione legacy: `010_invoice_status_terminals.sql`.
> - **Posting failure non è uno stato**. Su fallimento `postInvoice`/Release la fattura
>   resta in `POSTING_PENDING`/`PARKED` con il campo `PostingError` valorizzato +
>   audit `POSTING_FAILED`. Retry è disponibile via `CanPostInvoice` (esteso anche a
>   `PARKED`).
> - `REVERSED` è raggiungibile **solo** da `POSTED` o `ARCHIVED` via `reverseInvoice`
>   (chiamata Cancel S/4HANA). Tutti gli altri stati pre-posting NON producono REVERSED.

---
## §2 — Tabella canonica delle 6 fasi
### F1 — RECEPTION
| Voce | Valore |
|---|---|
| **Phase ID** | `F1_RECEPTION` |
| **Entry status** | `NEW` |
| **Exit status** | `NEW` (boundary verso BP_RESOLVED via `confirmPhase`) |
| **Statuses traversed** | `NEW` (legacy: `RECEIVED`/`XML_PARSED` su righe pre-migrazione 009 — vedi nota 2026-04-28) |
| **ProcessSteps** | `RECEIVE` (implicito) → `XML_PARSE` (implicito) — entrambi tracciati su `InboundMessage.Status`, non più sull'invoice |
| **Controlli** | nessun `ProcessStepCheck` formale — la parsing failure produce direttamente `ProcessingStatus=ERROR` |
| **Pre-condizioni** | XML FatturaPA non-null (canali SDI/Email/Webhook) **OR** payload strutturato (S4_STRUCTURED via `structuredS4SyncJob`) |
| **Post-condizioni / Gates** | `GeneralDocumentData_Number` + `_Date` + `_TotalAmount` valorizzati; `SupplierData_TaxCode` OR `_CodeId` valorizzato |
| **Mapping UI** | Wizard step `1`; Facet `SectionDocumento`; azioni FE: nessuna bound (creazione automatica via inbound adapter) |
### F2 — BP RESOLUTION
| Voce | Valore |
|---|---|
| **Phase ID** | `F2_BP_RESOLUTION` |
| **Entry status** | `XML_PARSED` |
| **Exit status** | `BP_RESOLVED` |
| **Statuses traversed** | `XML_PARSED` → `BP_RESOLVED` |
| **ProcessSteps** | `BP_RESOLUTION` (handler `resolveBusinessPartner`, `ProducedStatus=BP_RESOLVED`, `SeqNumber=10` STANDARD_IT) |
| **Controlli** | `BP_NOT_FOUND` ERROR/REQUIRE_APPROVAL · `BP_VATID_INVALID` ERROR/REQUIRE_APPROVAL · `BP_TAXCODE_MISSING` ERROR/SELF_SIGNED · `BP_NAME_MISMATCH` WARNING/SELF_SIGNED · `BP_LOW_CONFIDENCE` INFO/AUTO_RESOLVE · `BP_DELETION_FLAG` ERROR/REQUIRE_APPROVAL · `BP_POSTING_BLOCK` ERROR/NO_OVERRIDE · `BP_IBAN_INVALID` WARNING/REQUIRE_APPROVAL · `BP_MANUAL_ASSIGNMENT` ERROR/REQUIRE_APPROVAL |
| **Pre-condizioni** | `XML_PARSED` raggiunto + `SupplierData_TaxCode` OR `_CodeId` non-null |
| **Post-condizioni / Gates** | `ResolvedBPNumber` non-null; nessuna exception `BP_*` severity `ERROR` in stato `OPEN`/`IN_PROGRESS` |
| **Mapping UI** | Wizard step `2`; Facet `SectionRicezione` (Business Partner & Fornitore); azioni FE: `resolveBusinessPartner` (auto), edit manuale `ResolvedBPNumber` (sets `BPResolutionMethod=MANUAL`, `BPConfidence=100`); `confirmPhase` per uscita |
### F3 — VERIFICATION
| Voce | Valore |
|---|---|
| **Phase ID** | `F3_VERIFICATION` |
| **Entry status** | `BP_RESOLVED` |
| **Exit status** | `RISK_ASSESSED` (verso F4) **OR** `CREDIT_MATCHING` (deviazione F3_BIS per TD04, vedi §4.2) |
| **Statuses traversed** | `BP_RESOLVED` → `DUPLICATE_CHECK` → `VALIDATED` → `RISK_ASSESSED` |
| **ProcessSteps (ordine canonico design)** | `DUPLICATE_CHECK` (`SeqNumber=20`, `checkDuplicate`) → `FISCAL_VALIDATION` (`SeqNumber=30`, `validateFiscal`) → `RISK_ANALYSIS` (`SeqNumber=40`, `analyzeRisk`) — **drift D1**: oggi RISK è 60 |
| **Controlli — DUPLICATE_CHECK** | `DUP_EXACT_MATCH` ERROR/NO_OVERRIDE · `DUP_POTENTIAL_MATCH` WARNING/SELF_SIGNED · `OVERRIDE_DUPLICATE_ATTESTATION` ERROR/SELF_SIGNED |
| **Controlli — FISCAL_VALIDATION** | `FV_MANDATORY_FIELDS` ERROR/NO_OVERRIDE · `FV_SDI_TYPE_INVALID` ERROR/NO_OVERRIDE · `FV_VAT_INCONSISTENCY` ERROR/REQUIRE_APPROVAL(Tax) · `FV_REVERSE_CHARGE` WARNING/SELF_SIGNED · `FV_SPLIT_PAYMENT` WARNING/SELF_SIGNED · `FV_BOLLO_MISSING` WARNING/SELF_SIGNED · `RITENUTA_INCOMPLETE` WARNING/SELF_SIGNED · `FV_RITENUTA_AMOUNT_MISMATCH` WARNING/SELF_SIGNED · `FV_PAYMENT_DATE_INVALID` ERROR/REQUIRE_APPROVAL(Tax) · `FV_FISCAL_YEAR_CLOSED` WARNING/REQUIRE_APPROVAL(Tax) · `FV_CROSS_COMPANY` INFO/AUTO_RESOLVE · `RITENUTA_MANUAL_ASSIGNMENT` ERROR/REQUIRE_APPROVAL(Tax) · `BOLLO_MANUAL_CONFIRMATION` WARNING/SELF_SIGNED |
| **Controlli — RISK_ANALYSIS** | `RA_HIGH_SCORE` WARNING/SELF_SIGNED · `RA_ANOMALY_DETECTED` INFO/AUTO_RESOLVE |
| **Pre-condizioni** | `BP_RESOLVED` raggiunto; `ResolvedBPNumber` non-null |
| **Post-condizioni / Gates** | `DuplicateCheckStatus IN ('CLEAR','SUSPECTED','CONFIRMED_DUPLICATE')` (mai `NOT_CHECKED`); `FiscalValidationStatus` non-null; `RiskScore` calcolato; nessuna exception severity `ERROR` `OPEN`/`IN_PROGRESS` |
| **Mapping UI** | Wizard step `3`; Facet `SectionVerifiche`; azioni FE: `checkDuplicate`, `validateFiscal`, `analyzeRisk`, `runAllChecks`; `confirmPhase` per uscita |
### F4_PO — MATCHING (branch PO)
| Voce | Valore |
|---|---|
| **Phase ID** | `F4_PO_MATCHING` |
| **Entry status** | `MATCHING_PENDING` |
| **Exit status** | `THREE_WAY_OK` |
| **Statuses traversed** | `MATCHING_PENDING` → `PO_MATCHED` → `THREE_WAY_OK` |
| **ProcessSteps** | `PO_MATCH` (`SeqNumber=50`, `matchPurchaseOrder`) → `THREE_WAY_MATCH` (`SeqNumber=60`, `executeThreeWayMatch`) |
| **Controlli — PO_MATCH** | `PO_NOT_FOUND` ERROR/REQUIRE_APPROVAL · `PO_STATUS_CLOSED` ERROR/REQUIRE_APPROVAL · `PO_SUPPLIER_MISMATCH` ERROR/NO_OVERRIDE · `PO_HEADER_VALIDATION` ERROR/REQUIRE_APPROVAL · `PO_NOT_RELEASED` ERROR/REQUIRE_APPROVAL · `PO_DELETED` ERROR/REQUIRE_APPROVAL · `PO_CURRENCY_MISMATCH` ERROR/REQUIRE_APPROVAL · `PO_COMPANY_MISMATCH` ERROR/REQUIRE_APPROVAL · `PO_MANUAL_ASSIGNMENT` ERROR/REQUIRE_APPROVAL · `POSSIBLE_PO_MATCH` INFO/AUTO_RESOLVE · `OVERRIDE_NONPO_CONFIRM` ERROR/SELF_SIGNED |
| **Controlli — THREE_WAY_MATCH** | `TWM_QTY_VARIANCE` WARNING/SELF_SIGNED · `TWM_PRICE_VARIANCE` WARNING/REQUIRE_APPROVAL · `TWM_GR_MISSING` ERROR/REQUIRE_APPROVAL |
| **Pre-condizioni** | `RISK_ASSESSED` raggiunto; `InvoiceCategory != 'NON_PO'` |
| **Post-condizioni / Gates** | `MatchedPurchaseOrder` non-null; `ThreeWayMatchStatus` non-null; nessuna exception `PO_*`/`TWM_*` ERROR `OPEN`/`IN_PROGRESS` |
| **Mapping UI** | Wizard step `4`; Facet `SectionMatching` (visible if `InvoiceCategory != 'NON_PO'`); azioni FE: `matchPurchaseOrder`, `executeThreeWayMatch`, `unlinkPO`, `rectifyPoMatch`; `confirmPhase` per uscita |
### F4_NON_PO — CODING (branch NON_PO)
| Voce | Valore |
|---|---|
| **Phase ID** | `F4_NON_PO_CODING` |
| **Entry status** | `COST_ALLOCATION_PENDING` |
| **Exit status** | `COST_VALIDATED` (verso F5) **OR** `COST_ALLOCATED` se P1 cost-object validation è disabilitata |
| **Statuses traversed** | `COST_ALLOCATION_PENDING` → `COST_ALLOCATED` → `COST_VALIDATED` |
| **ProcessSteps** | `COST_ALLOCATION` (`SeqNumber=55`, `assignCostAllocation` → `confirmCostAllocation`) |
| **Controlli — COST_ALLOCATION** | `CA_GL_MISSING` ERROR/SELF_SIGNED · `CA_COST_OBJECT_MISSING` ERROR/SELF_SIGNED · `COST_ALLOCATION_INCOMPLETE` (audit-only) |
| **Phase gates (NON_PO)** | `COST_ALLOCATION_REQUIRED` ERROR/NO_OVERRIDE — raised entering F5 quando `AutoProcessingRule.RequireCostAllocation=true` per la company e nessuna allocation esiste |
| **Pre-condizioni** | `RISK_ASSESSED` raggiunto; `InvoiceCategory='NON_PO'` |
| **Post-condizioni / Gates** | Almeno 1 `InvoiceCostAllocation` row con sum%=100 e sumAmt = `invoice.TotalAmount`; cost-object validation passed se feature attiva |
| **Mapping UI** | Wizard step `4` (mutex con F4_PO via `InvoiceCategory`); Facet `SectionNonPO` (visible if `InvoiceCategory='NON_PO'`); azioni FE: `assignCostAllocation`, `validateCostObjects`, `confirmCostAllocation`, AI suggest; `confirmPhase` per uscita |
### F5 — APPROVAL
| Voce | Valore |
|---|---|
| **Phase ID** | `F5_APPROVAL` |
| **Entry status** | `PENDING_APPROVAL` |
| **Exit status** | `APPROVED` |
| **Statuses traversed** | `PENDING_APPROVAL` → `REWORK` (loop opzionale) → `APPROVED` |
| **ProcessSteps** | `APPROVAL` (`SeqNumber=70`, `sendForApproval`) |
| **Controlli** | gating via `ApprovalRule` + `WorkflowAdapterFactory` |
| **Pre-condizioni** | F4_PO completata (`THREE_WAY_OK`) **OR** F4_NON_PO completata (`COST_VALIDATED`/`COST_ALLOCATED`) **OR** F3_BIS completata (`CREDIT_MATCHED`) |
| **Post-condizioni / Gates** | tutti `InvoiceWorkflowItem` ItemType=`PHASE_APPROVAL` in stato `APPROVED`; nessun `EXCEPTION_OVERRIDE` `PENDING`/`IN_APPROVAL` |
| **Mapping UI** | Wizard step `5`; nessun Facet dedicato (workflow info in approvals app); azioni FE: `sendForApproval`, `approveSelected` (mass); su `InvoiceWorkflowItem`: `approveInvoice`, `rejectInvoice`, `delegateApproval`, `escalateWorkflow`, `cancelApproval` |
### F6 — POSTING
| Voce | Valore |
|---|---|
| **Phase ID** | `F6_POSTING` |
| **Entry status** | `APPROVED` |
| **Exit status** | `ARCHIVED` |
| **Statuses traversed** | `APPROVED` → `POSTING_PENDING` → `POSTED` → `ARCHIVED`; deviazioni: `PARKED`, `REVERSED` |
| **ProcessSteps** | `POSTING` (SeqNumber=80, `postInvoice`) → `ARCHIVING` (SeqNumber=90, `archiveInvoice`); deviazioni: `PARKING` (75, `parkInvoice`), `REVERSAL` (95, `reverseInvoice`) |
| **Controlli** | nessun `ProcessStepCheck` formale; validation via `simulatePosting` + S/4 OData error unwrap |
| **Pre-condizioni** | `APPROVED` raggiunto; `MatchedPurchaseOrder` valorizzato (PO) **OR** `InvoiceCostAllocation` confermate (NON_PO); nessun `PaymentBlock` ostacolante |
| **Post-condizioni / Gates** | `PostedInvoiceNumber` non-null + `ArchiveDocumentId` non-null + `ArchiveStatus='ARCHIVED'` |
| **Mapping UI** | Wizard step `6`; Facet `SectionRegistrazione` (nuovo, drift D10-bis); azioni FE: `simulatePosting`, `postInvoice`, `parkInvoice`, `resumeFromPark`, `deletePark`, `reverseInvoice`, `archiveInvoice`, `verifyArchive`, `uploadToDms` |
---
## §3 — Catalogo controlli per fase
### Tabella step → fase canonica
| Step ID | Fase canonica | SeqNumber design | Note vs codice attuale |
|---|---|---|---|
| `BP_RESOLUTION` | F2 | 10 | OK |
| `DUPLICATE_CHECK` | F3 | 20 | OK |
| `FISCAL_VALIDATION` | F3 | 30 | OK |
| `RISK_ANALYSIS` | F3 | 40 | DRIFT D1: oggi `STANDARD_IT.SeqNumber=60` |
| `PO_MATCH` | F4_PO | 50 | era 40, scala dopo riallineamento RISK |
| `THREE_WAY_MATCH` | F4_PO | 60 | era 50 |
| `COST_ALLOCATION` | F4_NON_PO | 55 | OK |
| `CREDIT_NOTE_MATCHING` | F3_BIS | 35 | check `CREDIT_NOTE_MATCH_MISSING` mal-bindato a `DUPLICATE_CHECK` (DRIFT D4) |
| `APPROVAL` | F5 | 70 | OK |
| `PARKING` | F6 (deviazione) | 75 | DRIFT D5: orfano (no template row) |
| `POSTING` | F6 | 80 | OK |
| `ARCHIVING` | F6 | 90 | OK |
| `REVERSAL` | F6 (deviazione) | 95 | DRIFT D5: orfano |
### Distribuzione 45 controlli per fase
- **F2** (9 check, step `BP_RESOLUTION`): `BP_TAXCODE_MISSING`, `BP_VATID_INVALID`, `BP_NAME_MISMATCH`, `BP_NOT_FOUND`, `BP_DELETION_FLAG`, `BP_POSTING_BLOCK`, `BP_IBAN_INVALID`, `BP_LOW_CONFIDENCE`, `BP_MANUAL_ASSIGNMENT`
- **F3** (16 check):
  - `DUPLICATE_CHECK` (3): `DUP_EXACT_MATCH`, `DUP_POTENTIAL_MATCH`, `OVERRIDE_DUPLICATE_ATTESTATION`
  - `FISCAL_VALIDATION` (11): `FV_MANDATORY_FIELDS`, `FV_SDI_TYPE_INVALID`, `FV_VAT_INCONSISTENCY`, `FV_REVERSE_CHARGE`, `FV_SPLIT_PAYMENT`, `FV_BOLLO_MISSING`, `RITENUTA_INCOMPLETE`, `FV_RITENUTA_AMOUNT_MISMATCH`, `FV_PAYMENT_DATE_INVALID`, `FV_FISCAL_YEAR_CLOSED`, `FV_CROSS_COMPANY`, `RITENUTA_MANUAL_ASSIGNMENT`, `BOLLO_MANUAL_CONFIRMATION`
  - `RISK_ANALYSIS` (2): `RA_HIGH_SCORE`, `RA_ANOMALY_DETECTED`
- **F3_BIS** (1 check): `CREDIT_NOTE_MATCHING` → `CREDIT_NOTE_MATCH_MISSING` (oggi mal-bindato — DRIFT D4)
- **F4_PO** (14 check):
  - `PO_MATCH` (11): `PO_NOT_FOUND`, `PO_STATUS_CLOSED`, `PO_SUPPLIER_MISMATCH`, `PO_HEADER_VALIDATION`, `PO_NOT_RELEASED`, `PO_DELETED`, `PO_CURRENCY_MISMATCH`, `PO_COMPANY_MISMATCH`, `PO_MANUAL_ASSIGNMENT`, `POSSIBLE_PO_MATCH`, `OVERRIDE_NONPO_CONFIRM`
  - `THREE_WAY_MATCH` (3): `TWM_QTY_VARIANCE`, `TWM_PRICE_VARIANCE`, `TWM_GR_MISSING`
- **F4_NON_PO** (2 check + 1 phase-gate):
  - `COST_ALLOCATION` (2): `CA_GL_MISSING`, `CA_COST_OBJECT_MISSING`
  - phase gate (1): `COST_ALLOCATION_REQUIRED` — chiarire come gate di fase (DRIFT D7)
- **F5** (0 check formali — gating via `ApprovalRule`)
- **F6** (0 check formali — gating via S/4 OData)
### Override policy — semantica canonica
| Policy | Comportamento atteso quando un check `ERROR` matcha |
|---|---|
| `AUTO_RESOLVE` | `InvoiceException` raised + auto-chiusa `Status=CLOSED_WITH_OVERRIDE`, `ClosureReason=SYSTEM_AUTO`. Audit-only, mai blocca. |
| `SELF_SIGNED` | `InvoiceException` `OPEN`, blocca `confirmPhase`. Risolvibile via "Richiedi Sblocco" self-attestation con justification. |
| `REQUIRE_APPROVAL` | `InvoiceException` `OPEN`, blocca `confirmPhase`. Genera `InvoiceWorkflowItem` ItemType=`EXCEPTION_OVERRIDE` assegnato a `OverrideApproverRole`. |
| `NO_OVERRIDE` | `InvoiceException` `OPEN`, blocca **permanentemente** `confirmPhase`. Solo path: correggere il dato e ri-eseguire. |
`JustificationRequired` (Boolean) è dimensione **ortogonale** alla policy: AUTO_RESOLVE forza `false` (invariante difensivo); SELF_SIGNED tipicamente `true`; REQUIRE_APPROVAL tipicamente `true`.
---
## §4 — Branch e deviazioni
### 4.1 NON_PO branch (F4_NON_PO)
**Trigger**: `InvoiceCategory='NON_PO'`.
**Routing**: in `confirmPhase`, le boundary `DUPLICATE_CHECK | VALIDATED | RISK_ASSESSED → next` valutano `inv => inv.InvoiceCategory === 'NON_PO' ? 'COST_ALLOCATION_PENDING' : 'MATCHING_PENDING'`.
**Status sequence**:
- `COST_ALLOCATION_PENDING` (entry F4_NON_PO)
- `COST_ALLOCATED` — `confirmCostAllocation` ha persistito allocation rows (totals quadrati); cost-object validation disabilitata
- `COST_VALIDATED` — allocation persistita + cost-object validation passed (CDS view C1)
**Phase gate (F3 → F5 NON_PO)**: `COST_ALLOCATION_REQUIRED` (ERROR / NO_OVERRIDE) — raised in `srv/matching/nonPoPhaseGate::raiseNonPoPhaseGateExceptions` quando `nextStatus === 'PENDING_APPROVAL'` AND `requiresCostAllocation(tx, invoice) === true` AND nessuna `InvoiceCostAllocation` row.
**Contratto handler `confirmCostAllocation`** (`srv/handlers/matchingActions.ts:562`):
1. Lock invoice via `forUpdate`
2. Read `costAllocationRepo.findByInvoice`
3. Reject 422 se `allocations.length===0`
4. Reject 422 se `|sum%-100| > 0.01` o `|sumAmt-invoice.TotalAmount| > 0.01`
5. `runCostObjectValidation` (P1: GL/CC/IO/WBS lookup vs CDS view C1) — strict mode + errori → reject 422
6. Update allocations `Status=CONFIRMED`
7. Update invoice `ProcessingStatus = validation.enabled ? 'COST_VALIDATED' : 'COST_ALLOCATED'`
8. `logAuditEntry action='COST_ALLOCATION_CONFIRMED'`
### 4.2 TD04 credit-note (F3_BIS)
**Trigger**: `SDIDocumentType='TD04'`.
**Decisione di disegno**: mantenere `CREDIT_MATCHING` reachable. Il branch ha 2 status: `CREDIT_MATCHING` (entry, in lavorazione) → `CREDIT_MATCHED` (matching confermato).
**Status sequence**:
- Entry `CREDIT_MATCHING` — fattura TD04 entrata in branch credit, in attesa abbinamento. Settata da boundary `RISK_ASSESSED → CREDIT_MATCHING` quando `SDIDocumentType='TD04'`.
- Exit `CREDIT_MATCHED` — `matchCreditNoteToOriginal` ha trovato e persistito match in `CreditNoteMatch`.
- `CREDIT_UNMATCHED` (deprecato): da rimuovere dal codice (DRIFT D3-bis). Failure path: lascia status pre-branch + raise exception `CREDIT_NOTE_MATCH_MISSING` OPEN.
**Phase gate (F3 → F3_BIS)**: `CREDIT_NOTE_MATCH_MISSING` (ERROR / SELF_SIGNED) — raised entering F5 quando `SDIDocumentType='TD04'` AND nessuna row in `CreditNoteMatch`.
**Contratto handler `matchCreditNoteToOriginal`** (`srv/handlers/creditNoteActions.ts`):
1. Read `creditNote` invoice
2. Reject 409 se `ProcessingStatus IN ('POSTED','ARCHIVED','REVERSED')`
3. Search original invoice (per supplier + reference fields)
4. Persist `CreditNoteMatch` row con tolerance (`CREDIT_NOTE_MATCH_TOLERANCE_PCT`)
5. Update invoice `ProcessingStatus = 'CREDIT_MATCHED'` (success); fallimento → no-op + raise exception
**Routing post-match**: `CREDIT_MATCHED` → fattura segue branch standard via `confirmPhase`; può essere PO o NON_PO a seconda di `InvoiceCategory`.
### 4.3 REWORK loop
**Trigger**: `rejectInvoice` action (su `InvoiceWorkflowItem`) con `req.data.allowRework !== false`.
**Effetto**:
- `ProcessingStatus` invoice → `REWORK`
- Workflow item → `Status=REJECTED`
- Notifica al `RequestedBy` (sanitizzata)
- L'invoice torna **modificabile** (FieldControl `Editable` ripristinato per i campi business)
**Re-entry path**: dopo correzione, l'utente esegue nuovamente `confirmPhase`. `REWORK` è dopo `PENDING_APPROVAL` in `STATUS_ORDER` ma prima di `APPROVED`, quindi `isBefore('REWORK','APPROVED')===true`.
**Distinzione vs `rollbackToPhase`**: `REWORK` riapre per modifica leggera (re-approval senza reset dati). `rollbackToPhase` esegue cascade-reset (azzera BP/PO/3-way/etc) e riporta a una fase precedente.
### 4.4 Stati terminali (post-refactor 2026-04-28)
| Status | Path di entry | Reversibile? | Note |
|---|---|---|---|
| `REJECTED` | `rejectAndCloseInvoice` (con `RejectReasonCode` obbligatorio); `rejectInvoice` con `allowRework=false` | Sì via `reopenInvoice` se `RejectReasonCode.Severity != 'HARD'`. Resume: `NEW` (default) o `BP_RESOLVED`; richieste verso `RECEIVED`/`XML_PARSED` legacy sono normalizzate silenziosamente a `NEW`. | Cancella exceptions OPEN come `DISMISSED`; cancella workflow PENDING/IN_APPROVAL come `CANCELLED`; **assorbe** anche tutti i casi che pre-2026-04-28 finivano in `CANCELLED` (rifiuto business, annullamento operativo, duplicato, non-processabile) — il distinguo è ora nel `RejectReasonCode`. |
| `REVERSED` | `reverseInvoice` (post `POSTED`/`ARCHIVED` solamente) | No | Crea reversal doc S/4 via Cancel FunctionImport; invalida archive. Non raggiungibile da `APPROVED`/`POSTING_PENDING`/`PARKED` (round-5 contract). |
| `CANCELLED` | **Non più usato come stato runtime** | — | Solo enum legacy per dati storici migrati a `REJECTED` da `010_invoice_status_terminals.sql`. |
| `ERROR` | **Non più usato come stato runtime** | — | Solo enum legacy. Su posting failure la fattura resta in `POSTING_PENDING` (o `PARKED`) con `PostingError` valorizzato + audit `POSTING_FAILED`. Migrazione 010 sposta righe legacy a `POSTING_PENDING` quando `PostedInvoiceNumber IS NULL`; con S/4 doc esistente lascia ERROR + audit `STATUS_TERMINAL_REVIEW_REQUIRED` per reconciliazione manuale. |
---
## §5 — Azioni trasversali
| Azione | Handler | Ruoli | Pre-condizioni | Side-effects | Status target |
|---|---|---|---|---|---|
| `setPaymentBlock(blockCode, reason)` | `paymentBlockActions.ts` | `Worker`, `Approver`, `Admin`, `OperationalAdmin` | `ProcessingStatus NOT IN ('POSTED','ARCHIVED','REVERSED','REJECTED','CANCELLED')` | Setta `PaymentBlock`; audit `PAYMENT_BLOCK_SET`; raise `InvoiceException PAYMENT_BLOCKED` WARNING OPEN; eligible auto-release | nessun cambio |
| `releasePaymentBlock(reason)` | `paymentBlockActions.ts` | come sopra | `PaymentBlock` non-null | Clear `PaymentBlock`; audit `PAYMENT_BLOCK_RELEASED`; chiude exception come `CLOSED_WITH_OVERRIDE` | nessun cambio |
| `advanceStatus(newStatus, reason)` | `adminOpsActions.ts` | `Admin`, `SuperAdmin` | `newStatus ∈ STATUS_ORDER` | Override forzato; audit `MANUAL_STATUS_OVERRIDE` con `oldValue`/`newValue`/`reason`; **NON** esegue cascade reset | qualsiasi |
| `rollbackToPhase(targetStatus)` | `invoiceLifecycleActions::rollbackToPhase` | `Admin`, `SuperAdmin`, `OperationalAdmin` | `targetStatus ∈ ROLLBACK_TARGETS = {NEW, BP_RESOLVED, DUPLICATE_CHECK, MATCHING_PENDING, COST_ALLOCATION_PENDING, PENDING_APPROVAL, POSTING_PENDING}`; `isBefore(target,current)` true | Cascade reset campi (BP, dup, PO, 3-way, fiscal, risk, costs); cascade delete `BPResolutionCheck`, `FiscalValidationCheck`, `ThreeWayMatchDetail`, `POHeaderValidationCheck`, `POMatchCandidate`, `InvoiceCostAllocation`, `InvoiceCostObjectCheck`; cancel workflow non-terminali | `targetStatus` |
| `parkInvoice` | `parkingActions.ts` | `Worker`, `Approver`, `PostingOfficer`, `Admin` | `ProcessingStatus IN ('APPROVED','VALIDATED','RISK_ASSESSED','THREE_WAY_OK')` | Crea preliminary FI doc parcheggiato S/4 | `PARKED` |
| `resumeFromPark` | `parkingActions.ts` | come sopra | `ProcessingStatus='PARKED'` | Release preliminary → real posting | `POSTED` |
| `deletePark` | `parkingActions.ts` | come sopra | `ProcessingStatus='PARKED'` | Cancella preliminary FI; clear `PostedInvoiceNumber` | `POSTING_PENDING` |
| `rejectAndCloseInvoice(reasonCode, detail)` | `invoiceLifecycleActions::rejectAndCloseInvoice` | `Worker`, `Approver`, `Admin`, `OperationalAdmin` | non terminale | Dismiss exceptions; cancel workflow PENDING; audit `INVOICE_REJECTED` | `REJECTED` |
| `reopenInvoice(resumeStatus?)` | `invoiceLifecycleActions::reopenInvoice` | `Worker`, `Approver`, `Admin` | `ProcessingStatus='REJECTED'`; reason non HARD; nessun workflow attivo | nuova validazione richiesta dall'utente | `NEW` (default) o `BP_RESOLVED`. Richieste verso `RECEIVED`/`XML_PARSED` legacy sono normalizzate a `NEW` silenziosamente. |
| `confirmPhase` | `invoiceLifecycleActions::confirmPhase` | `Worker`, `Approver`, `Admin`, `OperationalAdmin` | Boundary in `PHASE_BOUNDARIES`; gates di fase OK; nessuna exception ERROR open | Avanza alla `next phase` boundary; raise gates F3→F4_NON_PO e F3→F3_BIS; persiste `ProcessStepExecution`; audit `PHASE_CONFIRMED` | next status |
| `confirmStep(stepId)` | `invoiceLifecycleActions::confirmStep` | `Worker`, `Approver`, `Admin` | `ProcessStepExecution Result='PENDING_CONFIRMATION'` | Mark `COMPLETED`; trigger `processEngine.advanceAfterStep` | dipende dallo step |
| `processNext` | mass action | `Worker`, `Admin` | `ProcessingStatus AllowsProcessNext=true` | Esegue prossimo step automatico | dipende |
| `autoProcess` | `adminMaintenanceActions.ts` | `Admin`, `OperationalAdmin` | matcha `AutoProcessingRule` | Eseguie chain automatica fino al primo step manuale | dipende |
### 5.1 Vincoli di concurrency
- Tutte le azioni read+write su `Invoices` devono usare `forUpdate()` — pattern `invoiceRepo.findById(tx, id, cc, { lock: true })`.
- Eccezione documentata: `_maybeAutoReleasePaymentBlock` usa CAS idempotente (`invoiceRepo.casUpdateFields`).
- `cds.tx(req)` (request-scoped) sempre — mai `cds.tx({tenant}, async tx=>...)` (deadlock SQLite).
- `_sanitizeNotificationComment(text)` obbligatorio prima di outbound notification.
### 5.2 Auto-release pattern (payment block)
Quando un `EXCEPTION_OVERRIDE` workflow viene approvato e l'exception correlata aveva flag `PaymentBlockSetBy='SYSTEM_AUTO'`, il sistema auto-rilascia il PaymentBlock. Implementato in `_maybeAutoReleasePaymentBlock` (`exceptionActions.ts`). Idempotenza garantita dal marker `SYSTEM_AUTO`.
---
## §6 — Mapping UI
### 6.1 Phase ID → Wizard Step Number (FE)
| Phase ID | Wizard step | Status nello step | i18n label key |
|---|---|---|---|
| `F1_RECEPTION` | 1 | `NEW`, `RECEIVED`, `XML_PARSED` | `wizard_step_reception` |
| `F2_BP_RESOLUTION` | 2 | `BP_RESOLVED` | `wizard_step_bp` |
| `F3_VERIFICATION` | 3 | `DUPLICATE_CHECK`, `VALIDATED`, `RISK_ASSESSED` | `wizard_step_verification` |
| `F3_BIS_CREDIT` (deviazione TD04) | 3.5 (visibile solo se `SDIDocumentType='TD04'`) | `CREDIT_MATCHING`, `CREDIT_MATCHED` | `wizard_step_credit` |
| `F4_PO_MATCHING` (mutex F4_NON_PO) | 4 | `MATCHING_PENDING`, `PO_MATCHED`, `THREE_WAY_OK` | `wizard_step_matching` |
| `F4_NON_PO_CODING` (mutex F4_PO) | 4 | `COST_ALLOCATION_PENDING`, `COST_ALLOCATED`, `COST_VALIDATED` | `wizard_step_coding` |
| `F5_APPROVAL` | 5 | `PENDING_APPROVAL`, `REWORK`, `APPROVED` | `wizard_step_approval` |
| `F6_POSTING` | 6 | `POSTING_PENDING`, `POSTED`, `PARKED`, `ARCHIVED`, `REVERSED` | `wizard_step_posting` |
| Terminali off-flow | -1 (interrupted node) | `REJECTED`, `CANCELLED`, `ERROR` | `wizard_step_interrupted` |
### 6.2 Phase ID → ObjectPage Facet ID
| Phase ID | Facet ID | i18n label key |
|---|---|---|
| (cross-fase, sempre visibile) | `SectionDocumento` | `section_document` |
| `F1_RECEPTION` (overlap con Documento) | — | — |
| `F2_BP_RESOLUTION` | `SectionRicezione` (rinominare `SectionBP` futuro) | `phase_receiving` |
| `F3_VERIFICATION` | `SectionVerifiche` | `phase_verification` |
| `F3_BIS_CREDIT` | `SectionCreditoNota` (visible if `SDIDocumentType='TD04'`) | `section_creditoNota` |
| `F4_PO_MATCHING` | `SectionMatching` (visible if `InvoiceCategory != 'NON_PO'`) | `phase_matching` |
| `F4_NON_PO_CODING` | `SectionNonPO` (visible if `InvoiceCategory='NON_PO'`) | `phase_nonpo` |
| `F5_APPROVAL` | nessun facet (workflow in app approvals) | — |
| `F6_POSTING` | `SectionRegistrazione` (nuovo) | `phase_posting` |
### 6.3 Phase ID → Bound Action FE
| Phase ID | Action primaria | Action secondaria(e) |
|---|---|---|
| `F1_RECEPTION` | (auto via inbound) | — |
| `F2_BP_RESOLUTION` | `resolveBusinessPartner` | edit `ResolvedBPNumber`; `confirmPhase` |
| `F3_VERIFICATION` | `runAllChecks` | `checkDuplicate`, `validateFiscal`, `analyzeRisk`, `overrideDuplicate`; `confirmPhase` |
| `F3_BIS_CREDIT` | `matchCreditNoteToOriginal` | — |
| `F4_PO_MATCHING` | `matchPurchaseOrder` | `executeThreeWayMatch`, `unlinkPO`, `rectifyPoMatch`, `searchPOCandidates`, `promoteToPO`; `confirmPhase` |
| `F4_NON_PO_CODING` | `assignCostAllocation` | `validateCostObjects`, `confirmCostAllocation`, `suggestGLAccount`, `suggestCostCenter`, `classifyDPCategory`, `confirmNonPO`; `confirmPhase` |
| `F5_APPROVAL` | `sendForApproval` | `approveSelected`; su `InvoiceWorkflowItem`: `approveInvoice`, `rejectInvoice`, `delegateApproval`, `escalateWorkflow`, `cancelApproval` |
| `F6_POSTING` | `postInvoice` | `simulatePosting`, `parkInvoice`, `resumeFromPark`, `deletePark`, `reverseInvoice`, `archiveInvoice`, `verifyArchive`, `uploadToDms` |
| (cross-fase) | `setPaymentBlock`, `releasePaymentBlock` | `advanceStatus` (admin), `rollbackToPhase` (admin), `rejectAndCloseInvoice`, `reopenInvoice`, `processNext`, `autoProcess` |
---
## §7 — Invarianti di test
### 7.1 STATUS_ORDER (`srv/process/statusTransitions.ts`)
- **I-1**: `STATUS_ORDER` deve contenere esattamente l'unione degli status delle 6 fasi + F3_BIS + terminali. `CREDIT_UNMATCHED` deve essere assente.
- **I-2**: `idx('RISK_ASSESSED') < idx('MATCHING_PENDING') < idx('PO_MATCHED') < idx('THREE_WAY_OK') < idx('PENDING_APPROVAL')`.
- **I-3**: `idx('COST_ALLOCATION_PENDING') < idx('COST_ALLOCATED') < idx('COST_VALIDATED') < idx('PENDING_APPROVAL')`.
- **I-4**: `idx('CREDIT_MATCHING') < idx('CREDIT_MATCHED')`.
- **I-5**: parity CSV ↔ STATUS_ORDER garantita da `assertStatusOrderParity` — mantenere.
### 7.2 PHASE_BOUNDARIES (`srv/handlers/_invoiceActionsShared.ts`)
- **I-6**: `boundary.next(invoice)` deve produrre uno status `s'` tale che `Phase(s') = NextPhase(Phase(s))` (mai cross-fase salto >1).
- **I-7**: `PHASE_BOUNDARIES['COST_ALLOCATED']` e `['COST_VALIDATED']` esistono e puntano a `PENDING_APPROVAL`.
- **I-8**: `PHASE_BOUNDARIES['CREDIT_MATCHED']` deve esistere e instradare a `MATCHING_PENDING` o `COST_ALLOCATION_PENDING` basato su `InvoiceCategory`.
- **I-9**: `PHASE_ADVANCE_MAP` legacy va rimosso (test riscritto contro `buildPhaseBoundaries()`).
### 7.3 ProcessTemplate STANDARD_IT
- **I-10**: `BP_RESOLUTION=10 < DUPLICATE_CHECK=20 < FISCAL_VALIDATION=30 < RISK_ANALYSIS=40 < PO_MATCH=50 < THREE_WAY_MATCH=60 < APPROVAL=70 < POSTING=80 < ARCHIVING=90`.
- **I-11**: `COST_ALLOCATION` (SeqNumber=55) presente con `IsMandatory=false` (skip condition NON_PO).
- **I-12**: ogni `step_ID` in `ProcessTemplateStep.csv` esiste in `ProcessStep.csv`.
- **I-13**: ogni `ProcessStep.csv` referenziato da almeno un `ProcessTemplate` OR documentato come deviation.
- **I-14**: `PARKING` e `REVERSAL` deviation steps con `Category='POSTING'` + `BoundAction` valido.
### 7.4 ProcessStepCheck
- **I-15**: ogni check ha `step_ID` ∈ `ProcessStep.csv`.
- **I-16**: `CREDIT_NOTE_MATCH_MISSING.step_ID = 'CREDIT_NOTE_MATCHING'`.
- **I-17**: ogni check `OverridePolicy='AUTO_RESOLVE'` ha `JustificationRequired=false`.
- **I-18**: `COST_ALLOCATION_REQUIRED` classificato come phase gate (flag `IsPhaseGate=true`).
### 7.5 FE / annotation
- **I-19**: `ProgressFormatter.STATUS_STEP[s]` per ogni `s` coincide con il Wizard step number di §6.1.
- **I-20**: ProgressIndicator fragment include `CREDIT_MATCHING` in tutti gli array di status.
- **I-21**: `SectionMatching` ha `@UI.Hidden: HideIfNonPo`; `SectionNonPO` ha `@UI.Hidden: HideIfPo`.
- **I-22**: `UI.DataFieldForAction.Action` = `OrchestratorService.<actionName>` fully-qualified.
### 7.6 Runtime cross-handler
- **I-23**: nessun handler setta `ProcessingStatus='CREDIT_UNMATCHED'`.
- **I-24**: `confirmPhase` invoca `raiseNonPoPhaseGateExceptions` PRIMA del P2b blocking-error check.
- **I-25**: `cancelOrphanWorkflows` chiamato dopo ogni close exception via `SYSTEM_RECLASSIFY`.
- **I-26**: ogni handler che setta `ProcessingStatus` chiama `syncDraft` con stesso `updates` set.
### 7.7 Test E2E
- **T-1**: lifecycle PO happy path: `NEW → BP_RESOLVED → DUPLICATE_CHECK → VALIDATED → RISK_ASSESSED → MATCHING_PENDING → PO_MATCHED → THREE_WAY_OK → PENDING_APPROVAL → APPROVED → POSTING_PENDING → POSTED → ARCHIVED`. (esiste)
- **T-2**: lifecycle NON_PO happy path: `... RISK_ASSESSED → COST_ALLOCATION_PENDING → COST_VALIDATED → PENDING_APPROVAL → ...`. (estendere)
- **T-3**: lifecycle TD04 happy path: `... RISK_ASSESSED → CREDIT_MATCHING → CREDIT_MATCHED → MATCHING_PENDING/COST_ALLOCATION_PENDING → ... → POSTED`. (da scrivere)
- **T-4**: F3 internal ordering: dopo `BP_RESOLVED`, `checkDuplicate` deve eseguire prima di `validateFiscal` prima di `analyzeRisk`. (da scrivere)
- **T-5**: phase gate NON_PO: invoice NON_PO senza cost allocation → `confirmPhase` raise `COST_ALLOCATION_REQUIRED` exception OPEN. (esiste)
- **T-6**: phase gate TD04: invoice TD04 senza credit match → `confirmPhase` raise `CREDIT_NOTE_MATCH_MISSING` exception OPEN.
- **T-7**: REWORK loop: `rejectInvoice` da `PENDING_APPROVAL` → `REWORK`; modifica field; `confirmPhase` rilancia validation; `sendForApproval` torna a `PENDING_APPROVAL`. (esiste)
- **T-8**: rollback cascade: `rollbackToPhase('NEW')` da `THREE_WAY_OK` cancella check tables. (esiste)
- **T-9**: InvoiceCategory governance: PATCH diretto di `InvoiceCategory` rifiutato 403; mutazioni solo via `promoteToPO`/`promoteCandidate`/`reclassifyAsNonPO`. (esiste — `InvoiceCategoryGovernanceE2E.test.js`)
- **T-10**: workflow IN_APPROVAL contract: `delegateApproval`/`escalateWorkflow` mantengono `Status='IN_APPROVAL'` e aggiornano solo `AssignedTo`/`DelegatedFrom`/`EscalatedTo`. (esiste)
- **T-11**: posting failure non-state: su `postInvoice` failure la fattura resta in `POSTING_PENDING` con `PostingError`; retry disponibile via `CanPostInvoice` esteso a `PARKED`. (esiste — `InvoicePostingE2E.test.js`)
---

## §8 — Architectural decisions 2026-04-28
*Le decisioni architetturali del 2026-04-28 hanno semplificato la state machine. Riepilogo per riferimento:*

### 8.1 Single initial status — NEW
Prima del 2026-04-28 una nuova fattura passava da `NEW → RECEIVED → XML_PARSED → BP_RESOLVED`. I tre stati iniziali avevano semantica identica nel runtime (tutti routavano a `resolveBusinessPartner`, gating identico). La de-duplicazione collassa a `NEW` come unico stato iniziale; ingestione + parsing tracciati su `InboundMessage.Status` (`RECEIVED → PROCESSING → NORMALIZED → DUPLICATE/FAILED`). Migrazione legacy: `db/migrations/{pg,hana}/009_invoice_status_dedupe.sql`.

### 8.2 Terminali unificati
| Pre-2026-04-28 | Post-2026-04-28 |
|---|---|
| `CANCELLED` (pre-S/4 closure) | `REJECTED` con `RejectReasonCode` |
| `ERROR` (posting failure) | `POSTING_PENDING`/`PARKED` + `PostingError` field |

Migrazione: `010_invoice_status_terminals.sql`.

### 8.3 InvoiceCategory governance
`InvoiceCategory` muta **solo** tramite action esplicite:
- `promoteToPO` (NON_PO → PO, header)
- `promoteCandidate` (NON_PO → PO, row-level su `POMatchCandidates`)
- `reclassifyAsNonPO(justification)` (PO → NON_PO, motivazione obbligatoria ≥10 char)

Tutte e tre richiedono lo stato in `CLASSIFICATION_OPEN_STATES = {RISK_ASSESSED, COST_ALLOCATION_PENDING, COST_ALLOCATED, COST_VALIDATED, MATCHING_PENDING, PO_MATCHED, THREE_WAY_OK, REWORK}`. Il PATCH diretto è rifiutato 403 (anche per Admin) — defense-in-depth a 4 layer (CDS readonly + ACTION_PROTECTED_FIELDS + before-UPDATE guard + flag binding). Cleanup transazionale fail-closed (cost-allocation drop, line-item PO clear, 3-way wipe, audit `PO_RECLASSIFIED_TO_NON_PO`/`PO_REVERSE_USER_CONFIRMED_PO`).

### 8.4 Workflow IN_APPROVAL come unico stato azionabile
Tutti i path workflow (override request, multi-level chain, delegate, escalate) producono item con `Status='IN_APPROVAL'`. `PENDING`/`DELEGATED`/`ESCALATED` non sono più scritti dal runtime (solo enum legacy). `AssignedTo` + `DelegatedFrom` + `EscalatedTo` portano la semantica di provenienza. `RequiredRole_ID` si sposta con `AssignedTo` per coerenza con la role gate (handler `_resolveUserRoleId`).

### 8.5 SoD user-aware visibility
Nuovi flag virtuali popolati da `workflowItemReadEnrichment.ts`:
`CanCurrentUserApprove`, `CanCurrentUserReject`, `CanCurrentUserDelegate`, `CanCurrentUserEscalate`, `CanCurrentUserCancelApproval`. Combinano state + 4-eyes (RequestedBy != $user.id) + role match. Le annotation `@Core.OperationAvailable` bindano ai flag user-aware → niente più "button visible → 403 al click".

### 8.6 RM (Responsibility Management) come authority opzionale
`requestExceptionOverride` consulta `RMResponsibilityAdapter.findExceptionHandler` SOLO quando l'adapter attivo è RM (alias normalizzati: `rm`/`sap-rm`/`sap_rm`/`sap-responsibility`/`responsibility-management`). RM ritorna `{ userId, roleId, roleLabel }` — il caller usa `roleId` per `RequiredRole_ID` o valida che l'RM-user abbia il ruolo locale, droppando il role gate solo se mismatch. Client REST isolato in `srv/responsibility/rmClient.ts`, mapping in `rmRoleMapper.ts` (function/team → ProcessRole.Code/ID).

---
## Sign-off checklist
- [ ] §2 — le 6 schede di fase coincidono con il modello desiderato
- [ ] §3 — riallocazione `RISK_ANALYSIS` da F4 a F3 accettata
- [ ] §3 — binding correction `CREDIT_NOTE_MATCH_MISSING` accettata
- [ ] §3 — `COST_ALLOCATION_REQUIRED` come phase gate accettato
- [ ] §4 — `CREDIT_MATCHING` reachable, `CREDIT_UNMATCHED` rimosso
- [ ] §5 — tabella azioni trasversali completa
- [ ] §6.1 — wizard FE da 5 a 6 step
- [ ] §6.2 — facet `SectionMatching`/`SectionNonPO` mutex
- [ ] §6.3 — mappatura action→fase senza buchi
- [ ] §7 — 26 invarianti testabili come Jest unit tests
- [ ] La drift matrix riflette il delta verso questo design