Vai al contenuto
TechPYRE
Valkiria FluidodinamicaPortale ordini · oleodinamica industriale
ClienteOfficine Meccaniche Lario S.p.A.

Architettura e API

Endpoint REST provabili dal vivo e schema del database.

Flusso

  1. 1

    Next.js App Router

    Server e Client Components

  2. 2

    Controllo permessi

    Sessione firmata, deny-by-default

  3. 3

    Calcolo prezzi

    Funzione pura in centesimi

  4. 4

    PostgreSQL / Prisma

    Transazioni, optimistic locking

  5. 5

    Stripe e SDI

    Webhook firmati, FatturaPA

Endpoint REST

Route Handler reali: la risposta cambia con il ruolo scelto in alto.

GET/api/v1/catalog

Catalogo tecnico filtrabile

Accesso: buyer, tier1, agent, admin

Parametri ed esempi

Restituisce i componenti con specifiche, giacenze per magazzino e prezzo di listino. Il costo industriale è incluso solo per i ruoli con permesso price:view_cost (filtro applicato lato server).

Parametri di /api/v1/catalog
ParametroInTipo
role*Ruolo della sessione (in produzione derivato dal token, mai dal client).queryRole
qRicerca per SKU o nome.querystring
categoryFiltro categoria.queryProductCategory
availabilityPronta consegna o su ordinazione.queryin_stock | on_order

Esempio risposta HTTP 200

{
  "status": "ok",
  "role": "buyer",
  "count": 1,
  "items": [{
    "sku": "VLK-CT03-24V",
    "name": "Valvola Direzionale CETOP 03 ATEX 4/3 24 V DC",
    "maxPressureBar": 350,
    "flowLpm": 80,
    "listPriceCents": 48600,
    "stock": { "BG": 42, "BS": 18 },
    "leadTimeDays": 10
  }]
}

GET/api/v1/pricing/quote

Quotazione a scaglioni per SKU e quantità

Accesso: buyer, tier1, agent, admin

Parametri ed esempi

Esegue lo stesso pricing engine del client (centesimi interi): listino → sconto contratto della P.IVA → scaglione volume. Margine e provvigione sono esposti solo all'Agente Commerciale.

Parametri di /api/v1/pricing/quote
ParametroInTipo
sku*Codice articolo.querystring
qty*Quantità richiesta.queryinteger ≥ 1
role*Ruolo della sessione.queryRole
companyCliente attivo (solo Agente e Amministrazione).querylario | padana

Esempio risposta HTTP 200

{
  "status": "ok",
  "customer": { "vatNumber": "IT04928170961", "priceListTier": "OFFERTA_BASE" },
  "quote": {
    "sku": "VLK-CT03-24V",
    "quantity": 12,
    "listUnitCents": 48600,
    "contractUnitCents": 43740,
    "volumeTier": { "label": "10–49 pz", "discountBp": 800 },
    "netUnitCents": 40241,
    "lineNetCents": 482892
  }
}

POST/api/v1/orders

Emissione ordine con ricalcolo server-side

Accesso: buyer, tier1, agent

Parametri ed esempi

Valida la policy RBAC deny-by-default (order:submit, modalità di pagamento, esenzione IVA, override), ricalcola integralmente i totali ignorando i prezzi inviati dal client e restituisce il codice ordine. Il ruolo Amministrazione riceve 403.

Parametri di /api/v1/orders
ParametroInTipo
Idempotency-Key*Chiave univoca per evitare doppie emissioni in caso di retry.headeruuid
body*Ruolo, cliente, righe, regime IVA, pagamento.bodyOrderRequestBody
{
  "role": "tier1",
  "companyId": "padana",
  "vatRegime": "esente_art8",
  "paymentMethod": "bonifico_60_dffm",
  "lines": [{ "sku": "VLK-VMP-350", "quantity": 60, "overrideDiscountBp": 0 }]
}

Esempio risposta HTTP 201

{
  "status": "accepted",
  "orderCode": "VLK-ORD-2026-418273",
  "customerVat": "IT01928370154",
  "totals": {
    "taxableGoodsCents": 403860,
    "shippingCents": 0,
    "vatCents": 0,
    "stampDutyCents": 200,
    "grandTotalCents": 404060
  }
}

POST/api/webhooks/stripe

Ricezione eventi Stripe firmati

Accesso: Stripe (firma HMAC)

Chiamato all'invio ordine
Parametri ed esempi

Verifica l'header Stripe-Signature (HMAC SHA-256 su «timestamp.payload», tolleranza 300 s, confronto a tempo costante). In produzione deduplica su event.id con vincolo di unicità e risponde sempre 2xx ai duplicati.

Parametri di /api/webhooks/stripe
ParametroInTipo
Stripe-Signature*t=<unix>,v1=<hex HMAC>.headerstring
body*Payload grezzo, da verificare prima del parsing.bodyStripe.Event (raw)
{
  "id": "evt_1Q9xV2...",
  "object": "event",
  "type": "checkout.session.completed",
  "data": { "object": { "amount_total": 404060, "currency": "eur" } }
}

Esempio risposta HTTP 200

{
  "received": true,
  "signatureVerified": true,
  "eventId": "evt_1Q9xV2...",
  "type": "checkout.session.completed"
}

GET/api/v1/invoices/{id}/xml

Esportazione XML FatturaPA (FPR12)

Accesso: admin

Generato nel browser
Parametri ed esempi

Genera il tracciato FatturaPA della fattura con natura N3.5 per le dichiarazioni d'intento e bollo virtuale. Nella sandbox il file è prodotto lato client dalla stessa funzione pura.

Parametri di /api/v1/invoices/{id}/xml
ParametroInTipo
id*Identificativo fattura (inv_AAAA_NNNNNN).pathstring

Esempio risposta HTTP 200

<?xml version="1.0" encoding="UTF-8"?>
<p:FatturaElettronica versione="FPR12" ...>
  <FatturaElettronicaHeader>…</FatturaElettronicaHeader>
  <FatturaElettronicaBody>…</FatturaElettronicaBody>
</p:FatturaElettronica>

Schema database

prisma/schema.prisma · PostgreSQL · 184 righe

// Schema dati target del portale B2B Valkiria Fluidodinamica S.r.l. (azienda fittizia).
// Documentazione architetturale: nella sandbox pubblica i dati vivono in memoria,
// in produzione questo schema è applicato a PostgreSQL 16 tramite Prisma Migrate.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum Role {
  BUYER
  TIER1
  AGENT
  ADMIN
}

enum ContractTier {
  OFFERTA_BASE
  CONTRATTO_QUADRO
}

enum OrderStatus {
  DRAFT
  SUBMITTED
  PAID
  INVOICED
  CANCELLED
}

enum VatRegime {
  ORDINARIA
  ESENTE_ART8
}

enum WebhookOutcome {
  PROCESSED
  DUPLICATE_IGNORED
  SIGNATURE_INVALID
}

model Company {
  id                         String        @id @default(cuid())
  legalName                  String
  vatNumber                  String        @unique @db.VarChar(13)
  sdiRecipientCode           String        @db.Char(7)
  pec                        String
  priceListTierId            String
  priceListTier              PriceListTier @relation(fields: [priceListTierId], references: [id])
  freeShippingThresholdCents Int
  intentDeclarationProtocol  String?       @db.VarChar(32)
  stripeCustomerId           String?       @unique
  users                      User[]
  orders                     B2BOrder[]
  createdAt                  DateTime      @default(now())
  updatedAt                  DateTime      @updatedAt

  @@index([priceListTierId])
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  fullName  String
  role      Role
  companyId String?
  company   Company? @relation(fields: [companyId], references: [id])
  orders    B2BOrder[]
  createdAt DateTime @default(now())

  @@index([companyId, role])
}

model PriceListTier {
  id                 String       @id @default(cuid())
  tier               ContractTier @unique
  contractCode       String       @unique
  contractDiscountBp Int
  /// Scaglioni volume serializzati: [{ minQty, maxQty, discountBp }]
  volumeTiers        Json
  validFrom          DateTime
  validTo            DateTime?
  companies          Company[]
}

model Product {
  sku                 String      @id @db.VarChar(32)
  name                String
  category            String
  maxPressureBar      Int
  flowLpm             Int?
  tempMinC            Int
  tempMaxC            Int
  weightGrams         Int
  listPriceCents      Int
  industrialCostCents Int
  leadTimeDays        Int
  stockBergamo        Int         @default(0)
  stockBrescia        Int         @default(0)
  /// Optimistic locking: ogni decremento giacenza incrementa la versione.
  version             Int         @default(0)
  orderLines          OrderLine[]
  updatedAt           DateTime    @updatedAt

  @@index([category])
}

model B2BOrder {
  id              String      @id @default(cuid())
  orderCode       String      @unique
  /// Chiave di idempotenza inviata dal client: impedisce doppie emissioni.
  idempotencyKey  String      @unique
  companyId       String
  company         Company     @relation(fields: [companyId], references: [id])
  createdById     String
  createdBy       User        @relation(fields: [createdById], references: [id])
  status          OrderStatus @default(SUBMITTED)
  vatRegime       VatRegime
  paymentMethod   String
  taxableCents    Int
  shippingCents   Int
  vatCents        Int
  stampDutyCents  Int         @default(0)
  totalCents      Int
  stripeSessionId String?     @unique
  invoiceNumber   String?     @unique
  sdiIdentifier   String?
  lines           OrderLine[]
  webhookLogs     WebhookLog[]
  createdAt       DateTime    @default(now())

  @@index([companyId, createdAt(sort: Desc)])
  @@index([status])
}

model OrderLine {
  id                 String   @id @default(cuid())
  orderId            String
  order              B2BOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
  sku                String
  product            Product  @relation(fields: [sku], references: [sku])
  quantity           Int
  listUnitCents      Int
  netUnitCents       Int
  volumeTierLabel    String
  overrideDiscountBp Int      @default(0)
  lineNetCents       Int
  fromBergamo        Int
  fromBrescia        Int
  backorder          Int

  @@unique([orderId, sku])
  @@index([sku])
}

/// Ogni consegna (anche duplicata o con firma non valida) viene registrata.
model WebhookLog {
  id                String         @id @default(cuid())
  stripeEventId     String
  type              String
  orderId           String?
  order             B2BOrder?      @relation(fields: [orderId], references: [id])
  signatureVerified Boolean
  outcome           WebhookOutcome
  payload           Json
  receivedAt        DateTime       @default(now())

  @@index([stripeEventId])
  @@index([orderId])
  @@index([type, receivedAt(sort: Desc)])
}

/// Deduplica: un evento Stripe produce effetti di dominio al più una volta.
/// L'INSERT avviene nella stessa transazione dell'effetto; la violazione della
/// chiave primaria segnala un duplicato → risposta 200 con outcome DUPLICATE_IGNORED.
model ProcessedStripeEvent {
  eventId     String   @id
  type        String
  processedAt DateTime @default(now())
}