Passa al contenuto principale

Servizi - dominio inv

🎯 Cosa fa​

Il package TrainingHub.BackOffice.Services.Invoicing contiene la logica applicativa del dominio fatturazione: numerazione, invio SDI via Aruba, generazione XML FatturaPA, righe fattura, piano rate e storico stati. I servizi elencati sotto sono tutti mockabili per testing.

Generatore FatturaPA e client Aruba non stanno qui: arrivano dai pacchetti 3SD Palanca (Palanca.EInvoicing.Shared, Palanca.EInvoicing.Aruba), che sono stati estratti da questo codice. TrainingHub tiene solo la traduzione delle sue entità nel modello di Palanca, le credenziali per emittente, i messaggi per l'operatore e la riduzione degli stati SDI ai suoi InvoiceStatus.

🗺️ Servizi​

ServizioResponsabilità
IInvoiceService / InvoiceServiceOrchestrazione fattura (numerazione, send, refresh, rate, storico, precompilazione nota di credito)
IInvoiceBillingService / InvoiceBillingServiceCostruzione righe fattura (da corsi, sessioni dedicate e reminder) e copia righe per la nota di credito
IArubaSdiService / ArubaSdiServiceAruba per TrainingHub sopra IArubaClient di Palanca: credenziali dall'emittente, esiti dell'upload come messaggi, stato e PDF
FatturaPaInvoiceMapperEntità TrainingHub → FatturaPaInvoice di Palanca, ed estensione Generate(FatturaPaXmlInput) su IFatturaPaGenerator
IPaymentTermsService / PaymentTermsService + PaymentTermsCalculatorPiani di pagamento per azienda (inv.companyPaymentTerms), con default globale e calcolo scadenze daysOffset/endOfMonth
IInvoiceCourtesyCopyService / InvoiceCourtesyCopyServiceCopia di cortesia della fattura accettata (la _S di page-inv-invoices)
InvoiceLockRulesRegole di lock della fattura per stato
InvoiceTotalsRefresherRicalcolo dei totali
InvoiceLineDescriptionBuilderComposizione della descrizione riga
InvoiceStatusRefreshWorkerBackgroundService di refresh stati ogni 10 minuti

Helper statico di mapping:

HelperRuolo
InvoiceStatusExtensionsRiduce EInvoiceStatus di Palanca a InvoiceStatus
InvoiceActionRunnerPattern condiviso try → action → toast successo → reload → catch toast errore. Usato da Invoice.razor.cs (grid) e da InvoiceFormPopup.StepSummary.cs per Send/Refresh/PreviewXml

Tutti registrati in Program.cs come scoped/transient per DI.

🔧 API pubblica​

IInvoiceService​

Task<invoice> AssignNumberAsync(Guid invoiceId, CancellationToken ct);
Task<string> GeneratePreviewXmlAsync(Guid invoiceId, CancellationToken ct = default);
Task SendToArubaAsync(Guid invoiceId, CancellationToken ct);
Task RefreshStatusFromArubaAsync(Guid invoiceId, CancellationToken ct);
Task AddStatusHistoryAsync(Guid invoiceId, InvoiceStatus status, string? arubaStatus, string? notes, string? createdBy, CancellationToken ct);
Task<IEnumerable<invoiceStatusHistory>> GetStatusHistoryAsync(Guid invoiceId, CancellationToken ct);
Task<IEnumerable<invoicePayment>> GetPaymentsAsync(Guid invoiceId, CancellationToken ct);
Task SavePaymentsAsync(Guid invoiceId, IEnumerable<invoicePayment> payments, CancellationToken ct);
invoice BuildCreditNoteHeader(invoice source);

BuildCreditNoteHeader costruisce in memoria (non persiste) l'header di una nota di credito TD04 a partire dalla fattura d'origine: stesso issuerId/companyId, documentTypeCode = "TD04", issueDate = oggi, status = Draft, relatedInvoiceId = source.id, causale automatica ("Nota di credito a storno della fattura {code} del {issueDate}"), id = Guid.Empty (assegnato al salvataggio della bozza dal wizard).

Helper statici puri (testabili senza DB):

public static decimal ComputePaymentAmount(decimal totalAmount, decimal percentage);

IInvoiceBillingService​

Task<int> AddCourseLinesAsync(Guid invoiceId, IReadOnlyCollection<Guid> trainingSessionsCourseIds, CancellationToken ct = default);
Task<int> AddSessionLinesAsync(Guid invoiceId, IReadOnlyCollection<Guid> trainingSessionIds, CancellationToken ct = default);
Task<int> AddReminderLinesAsync(Guid invoiceId, IReadOnlyCollection<Guid> reminderIds, CancellationToken ct = default);
Task FreeLineSourceAsync(invoiceLine line, CancellationToken ct = default);
Task<int> CopyLinesFromInvoiceAsync(Guid sourceInvoiceId, Guid targetInvoiceId, CancellationToken ct = default);

AddCourseLinesAsync legge inv.vw_billableEnrollments (non l'aggregato vw_billableCourses, che serve solo la griglia) filtrata sull'azienda della fattura e sui corsi di sessione scelti, raggruppa in memoria per corso e per ciascun gruppo inserisce una riga (quantity = iscrizioni del gruppo, prezzo scontato dalla convenzione della categoria del corso) più le righe ponte inv.invoiceLineEnrollments corrispondenti, nella stessa transazione: riga e registro non possono divergere perché nascono dalla stessa lista. AddSessionLinesAsync crea una riga a corpo per sessione dedicata (prezzo netto, nessuno sconto convenzione); il registro è la riga stessa (origin='session').

CopyLinesFromInvoiceAsync copia le righe della fattura sorgente sulla nota di credito target, rinumerate da 1, con origin/sourceId = NULL (righe manuali: non riconsumano i registri corso/sessione/reminder della fattura d'origine) e importi positivi - copia 1:1, perché lo storno è segnalato dal tipo documento TD04, non dal segno degli importi. Idempotente: no-op se il target ha già righe, così un "Procedi" ripetuto nel wizard non duplica.

IArubaSdiService​

Task<string> SendInvoiceAsync(issuer issuer, string xmlContent, string fileName, CancellationToken ct);
Task<ArubaStatusResult> CheckStatusAsync(issuer issuer, string transmissionId, CancellationToken ct);
Task<byte[]?> GetInvoicePdfAsync(issuer issuer, string transmissionId, CancellationToken ct);

public record ArubaStatusResult(
InvoiceStatus Status,
string? SdiProtocolNumber,
string? RawArubaStatus,
string? NotificationXml);

L'interfaccia accetta l'oggetto issuer perché l'account Aruba è per-emittente: lo stesso tenant può avere emittenti con account Aruba diversi. Username e password non stanno sul DB: ArubaSdiService li legge da Aruba:Accounts:<P.IVA senza prefisso> (ArubaAccountsOptions; nel Key Vault i segreti backoffice--Aruba--Accounts--<piva>--Username e --Password, su dev 1Password), l'ambiente da issuer.arubaEnvironment, e ne fa le ArubaCredentials di Palanca a ogni chiamata. Se mancano, l'operazione fallisce con un messaggio che nomina la chiave mancante.

Il client di Palanca restituisce gli errori di business di Aruba come esito (Uploaded / Duplicate / Failed); SendInvoiceAsync trasforma ogni esito diverso da Uploaded in InvalidOperationException, aggiungendo per 0034 (file già ricevuto) e 0093 (delega mancante) la spiegazione di cosa fare. Retry sul 429, cache del token e rinnovo su 401/403 li fa Palanca; il 429 persistente arriva come Palanca.EInvoicing.Aruba.ArubaRateLimitException.

Generazione XML: FatturaPaInvoiceMapper​

// IFatturaPaGenerator viene da Palanca.EInvoicing.Shared
string Generate(this IFatturaPaGenerator generator, FatturaPaXmlInput input);
FatturaPaInvoice Map(FatturaPaXmlInput input);
FatturaPaVatId SellerVatId(issuer issuer);
// nome file SDI: FatturaPaFileName.For(SellerVatId(issuer), numero) → "IT01234567890_00001.xml"

public record FatturaPaXmlInput(
issuer Issuer,
invoice Invoice,
IReadOnlyList<invoiceLine> Lines,
company Company,
IReadOnlyList<invoicePayment> Payments,
IReadOnlyList<vatCode> VatCodes,
IReadOnlyList<bankAccount> BankAccounts,
string? RelatedDocumentNumber = null,
DateTime? RelatedDocumentDate = null);

Genera XML conforme al tracciato FatturaPA 1.2 pronto per SDI. Le scelte di TrainingHub stanno nel mapper: RegimeFiscale RF01, ProgressivoInvio dal numero fattura, bollo di default 2,00, IdPaese del destinatario sempre IT, nessuna provincia del destinatario. Gli errori di validazione di Palanca (codici stabili, messaggi in inglese) diventano InvalidOperationException in italiano. I file golden in TrainingHub.UnitTests/Services/Invoicing/Golden/, scritti dal vecchio generatore di TrainingHub, tengono fermo l'output al byte.

  • TipoDocumento dinamico: renderizzato da invoice.documentTypeCode (non più fisso a TD01), quindi riflette qualsiasi tipo configurato in inv.documentTypes (TD01 fattura, TD04 nota di credito, ecc.).
  • DatiFattureCollegate: emesso solo se RelatedDocumentNumber è valorizzato (fatture con relatedInvoiceId, cioè le note di credito) - riporta IdDocumento (codice della fattura originale) ed eventualmente Data (data emissione originale). InvoiceService popola questi due campi risolvendo invoice.relatedInvoiceId.
  • DatiPagamento opzionale: emesso solo se ci sono rate (payments.Count > 0); per le note di credito, dove il piano rate è facoltativo, il nodo viene omesso invece di lanciare un errore.

InvoiceStatusExtensions​

public static InvoiceStatus ToInvoiceStatus(this EInvoiceStatus status);

Palanca legge lo stato testuale Aruba (ArubaStatusMapping) e le notifiche SDI (EInvoiceStatusRules) in un EInvoiceStatus a grana fine; TrainingHub lo riduce così:

EInvoiceStatus (notifica SDI)→ InvoiceStatus
Delivered (RC), Accepted (EC01), DeadlinePassed (DT)Accepted
Discarded (NS), ProcessingErrorDiscarded
Refused (EC02, o EC senza esito)Rejected
NotDelivered (MC), Undeliverable (AT)UndeliverableReceipt
Submitted, Sent, UnknownSent

⚠️ DT (decorrenza termini) mappa su Accepted: è l'accettazione tacita del destinatario che non ha risposto entro i termini. MC e AT sono fatture emesse e valide, depositate nel cassetto fiscale del destinatario.

🧩 Pattern chiave​

Flusso SendToArubaAsync​

  1. Carica invoice dal DB → lancia se non esiste.
  2. Verifica che InvoiceStatus == Draft → lancia altrimenti.
  3. Carica issuer, company, lines, payments, vatCodes.
  4. Valida che ci siano rate - tranne per le note di credito (documentTypeCode == "TD04"), dove il piano rate è facoltativo e la validazione è saltata.
  5. Costruisce FatturaPaXmlInput e genera l'XML via IFatturaPaGenerator (Palanca) passando dal mapper.
  6. Chiama IArubaSdiService.SendInvoiceAsync → ottiene transmissionId.
  7. Aggiorna invoice: status = Sent, arubaTransmissionId = ..., sentXmlContent = xml, updatedAt = now.
  8. Inserisce riga in invoiceStatusHistory.

Ogni step fallisce fast con InvalidOperationException dal messaggio descrittivo (catturato dal code-behind UI e mostrato via toast).

Flusso RefreshStatusFromArubaAsync​

  1. Carica invoice, verifica presenza arubaTransmissionId.
  2. Carica issuer (per credenziali Aruba).
  3. Chiama IArubaSdiService.CheckStatusAsync(issuer, transmissionId).
  4. Aggiorna stato invoice da ArubaStatusResult (via InvoiceStatusExtensions.ToInvoiceStatus).
  5. Registra cambio in invoiceStatusHistory con arubaStatus grezzo.

Numerazione all'emissione​

Il numero non viene assegnato alla bozza: invoiceNumber/invoiceYear/code restano NULL finché la fattura non viene emessa. AssignNumberAsync (chiamato da SendToArubaAsync prima di generare l'XML) è idempotente e assegna il numero in transazione incrementando la riga contatore inv.invoiceCounters.

Serie per tipo documento con fallback: la serie è individuata da (issuerId, documentTypeCode, year). AssignNumberAsync usa la serie dedicata al documentTypeCode della fattura se configurata, altrimenti ricade sulla serie di default (documentTypeCode IS NULL) condivisa da tutti i tipi. Così, senza configurazione, tutti i documenti condividono un'unica numerazione; per separare (es.) le note di credito basta creare un contatore TD04.

Codice via templating: code è renderizzato con Brighela.Templating.TemplatingEngine.RenderAsync(counter.templatingExpression, …) (Handlebars, variabili progressive/year/documentTypeCode). Un contatore creato al volo usa il default {{year}}/{{progressive}} (InvoiceService.DefaultCodeTemplate); il roll-over annuale eredita il template dall'ultimo anno se la serie non è terminated.

In caso di collisione concorrente ritenta (max 3): l'unicità (issuerId, documentTypeCode, anno, numero) è garantita dall'indice filtrato UQ_invoices_number (WHERE invoiceNumber IS NOT NULL, così più bozze senza numero convivono).

Nota di credito da fattura (TD04)​

Flusso innescato dal comando riga "Crea nota di credito" sull'elenco fatture (Invoice.razor.cs.HandleCreateCreditNote), disponibile per fatture Accettata/ImpossibileRecapito che non siano già una nota di credito:

  1. InvoiceService.BuildCreditNoteHeader(source) costruisce l'header in memoria (vedi sopra) e apre il wizard fattura precompilato.
  2. Al primo "Procedi" del wizard (InvoiceFormPopup.SaveInvoiceDraft), la bozza viene inserita e, poiché relatedInvoiceId è valorizzato, InvoiceBillingService.CopyLinesFromInvoiceAsync copia le righe della fattura originale (vedi sopra).
  3. Il piano rate resta facoltativo (passo Pagamenti del wizard skippabile).
  4. All'invio, BuildXmlInputAsync risolve invoice.relatedInvoiceId per popolare RelatedDocumentNumber/RelatedDocumentDate nel FatturaPaXmlInput, da cui il generatore di Palanca genera il nodo DatiFattureCollegate e il TipoDocumento = TD04.

SavePaymentsAsync: delete-then-insert​

Il metodo cancella tutte le rate esistenti e reinserisce quelle fornite. Non è un upsert. Side effect: muta invoiceId, lineNumber, amount degli oggetti in input.

int lineNumber = 1;
foreach (var p in payments)
{
p.invoiceId = invoiceId;
p.lineNumber = lineNumber++;
p.amount = ComputePaymentAmount(invoice.totalAmount, p.percentage);
await _db.InsertAsync<...>(p, ct);
}

Implicazione: gli ID delle rate cambiano a ogni salvataggio. Non referenziare rate per ID da fuori della fattura.

History sempre scritto​

Ogni cambio di stato passa per AddStatusHistoryAsync, chiamato da SendToArubaAsync e RefreshStatusFromArubaAsync. Non esistono path che cambiano status senza registrarlo in invoiceStatusHistory.

📦 Dipendenze​

InvoiceService dipende da:

  • ISimpleCRUDService (Brighela.SimpleCRUD) - accesso DB generico (GetAsync/GetListAsync/InsertAsync/UpdateAsync/DeleteAsync con parametri SQL).
  • IFatturaPaGenerator (Palanca) - generazione XML (injectable, mockabile su Generate(FatturaPaInvoice)).
  • IArubaSdiService - Aruba per TrainingHub (injectable, mockabile); sotto usa IArubaClient di Palanca.

Registrazione DI in Program.cs:

builder.Services
.AddFatturaPaGenerator() // Palanca.EInvoicing.Shared
.AddPalancaAruba() // Palanca.EInvoicing.Aruba: client, cache token, HttpClient
.AddScoped<IInvoiceService, InvoiceService>()
.AddScoped<IArubaSdiService, ArubaSdiService>();

📁 File chiave​

  • Services/Invoicing/IInvoiceService.cs + InvoiceService.cs - core
  • Services/Invoicing/IArubaSdiService.cs + ArubaSdiService.cs - Aruba sopra Palanca
  • Services/Invoicing/FatturaPaInvoiceMapper.cs - entità → modello FatturaPA
  • Services/Invoicing/InvoiceStatusExtensions.cs - riduzione stati SDI
  • TrainingHub.UnitTests/Services/Invoicing/ - unit test sull'area, con i golden FatturaPA in Golden/

🔌 Estensione tipica​

Aggiungere un metodo al servizio​

  1. Aggiungere signature in IInvoiceService.
  2. Implementare in InvoiceService.
  3. Scrivere test in InvoiceServiceTests (con mock dei 3 dependency).
  4. Chiamare dal code-behind UI via [Inject] IInvoiceService invoiceService.

Cambiare il mapping stati SDI​

Modificare InvoiceStatusExtensions.ToInvoiceStatus. Helper puro, testabile in isolation. La lettura del testo Aruba e delle notifiche è di Palanca: un'etichetta Aruba nuova si aggiunge lì.

Mockare Aruba in test​

Implementare una stub di IArubaSdiService che restituisce transmissionId fisso per SendInvoiceAsync e un ArubaStatusResult configurato per CheckStatusAsync. Iniettare nella testbench senza DB reale.

⚠️ Debito tecnico​

  • SendToArubaAsync monolitico. Il metodo fa validazione + caricamento dati + generazione XML + invio + update + history. Valutare split in metodi privati o pipeline step-per-step per leggibilità/test.
  • Messaggi di errore hardcoded in italiano. throw new InvalidOperationException("La fattura non ha rate di pagamento...") non è localizzato. Spostare in risorse se la UI in futuro mostra testo raw. Oggi mitigato dal toast che incapsula il messaggio.
  • SavePaymentsAsync mutates input. Side-effect non dichiarato nei parametri. Considerare record/immutable e restituire le nuove rate, o rendere esplicito nel nome (SaveAndAssignAsync).
  • Nessun retry/backoff su IArubaSdiService.SendInvoiceAsync. C'è, in Palanca: ArubaClient.UploadAsync fa un solo retry sul 429 (fino a 30 secondi, rispettando Retry-After), poi lancia ArubaRateLimitException. Coperto dai test di Palanca.EInvoicing.Aruba. Resta aperto il tema dell'idempotency key: un timeout su una chiamata andata a buon fine può ancora causare un doppio invio al ritentativo manuale.
  • GetPaymentsAsync inclusa in IInvoiceService ma è una pura query. Se la superficie dell'interfaccia cresce, valutare split (IInvoiceQueries vs IInvoiceCommands).

🔗 Vedi anche​