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. La cartella contiene 23 file; i servizi elencati sotto sono tutti mockabili per testing.

🗺️ Servizi

ServizioResponsabilità
IInvoiceService / InvoiceServiceOrchestrazione fattura (numerazione, send, refresh, rate, storico, precompilazione nota di credito)
IInvoiceBillingService / InvoiceBillingServiceCostruzione righe fattura (da appuntamenti/reminder) e copia righe per la nota di credito
IArubaSdiService / ArubaSdiServiceIntegrazione HTTP con API Aruba (invio + polling stato), con un retry e gestione del 429
IFatturaPaXmlGenerator / FatturaPaXmlGeneratorGenerazione dell'XML FatturaPA 1.2
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
InvoiceStatusExtensionsMappa codici notifica SDI (RC, NS, ecc.) 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> AddAppointmentLinesAsync(Guid invoiceId, IReadOnlyCollection<Guid> appointmentIds, 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);

CopyLinesFromInvoiceAsync copia le righe della fattura sorgente sulla nota di credito target, rinumerate da 1, con origin/sourceId = NULL (righe manuali: non riconsumano i bridge appuntamento/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);

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

L'interfaccia accetta l'oggetto issuer perché le credenziali Aruba (username, password, ambiente) sono per-emittente: lo stesso tenant può avere emittenti con account Aruba diversi.

IFatturaPaXmlGenerator

string Generate(FatturaPaXmlInput input);
string GetFileName(issuer issuer, int invoiceNumber);
// formato: IT{vatCode}_{progressivo5cifre}.xml → "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.

  • 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 SdiNotificationCode? ParseSdiCode(string? code);
public static InvoiceStatus FromArubaNotification(SdiNotificationCode? code);

Mapping:

Codice SDI→ InvoiceStatus
RC, AT, DTAccepted
NS, MCDiscarded
ECRejected
SEUndeliverableReceipt
(nullo / non riconosciuto)Sent

⚠️ DT (decorrenza termini) mappa su Accepted, non su UndeliverableReceipt: è l'accettazione tacita del destinatario che non ha risposto entro i termini. Solo SE è mancata consegna (InvoiceStatusExtensions.cs:22-25).

🧩 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 IFatturaPaXmlGenerator.
  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.FromArubaNotification).
  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 FatturaPaXmlGenerator 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).
  • IFatturaPaXmlGenerator — generazione XML (injectable, mockabile).
  • IArubaSdiService — integrazione HTTP Aruba (injectable, mockabile).

Registrazione DI in Program.cs:

builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<IArubaSdiService, ArubaSdiService>();
builder.Services.AddScoped<IFatturaPaXmlGenerator, FatturaPaXmlGenerator>();

📁 File chiave

  • Services/Invoicing/IInvoiceService.cs + InvoiceService.cs — core
  • Services/Invoicing/IArubaSdiService.cs + ArubaSdiService.cs — integrazione HTTP Aruba
  • Services/Invoicing/IFatturaPaXmlGenerator.cs + FatturaPaXmlGenerator.cs — generazione XML 1.2
  • Services/Invoicing/InvoiceStatusExtensions.cs — mapping codici SDI
  • TrainingHub.UnitTests/Services/Invoicing/ — 21 file di copertura unit test sull'area

🔌 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.FromArubaNotification. Helper puro, testabile in isolation.

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'è: ArubaSdiService.cs:53-56 fa un solo retry — «oltre, l'errore deve arrivare all'utente» — attendendo ex.RetryAfter ?? 5s; e :220-229 traduce il 429 in ArubaRateLimitException rispettando l'header Retry-After. Coperto da ArubaSdiServiceRetryTests.cs e ArubaSdiServiceRateLimitTests.cs. 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