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
| Servizio | Responsabilità |
|---|---|
IInvoiceService / InvoiceService | Orchestrazione fattura (numerazione, send, refresh, rate, storico, precompilazione nota di credito) |
IInvoiceBillingService / InvoiceBillingService | Costruzione righe fattura (da appuntamenti/reminder) e copia righe per la nota di credito |
IArubaSdiService / ArubaSdiService | Integrazione HTTP con API Aruba (invio + polling stato), con un retry e gestione del 429 |
IFatturaPaXmlGenerator / FatturaPaXmlGenerator | Generazione dell'XML FatturaPA 1.2 |
IPaymentTermsService / PaymentTermsService + PaymentTermsCalculator | Piani di pagamento per azienda (inv.companyPaymentTerms), con default globale e calcolo scadenze daysOffset/endOfMonth |
IInvoiceCourtesyCopyService / InvoiceCourtesyCopyService | Copia di cortesia della fattura accettata (la _S di page-inv-invoices) |
InvoiceLockRules | Regole di lock della fattura per stato |
InvoiceTotalsRefresher | Ricalcolo dei totali |
InvoiceLineDescriptionBuilder | Composizione della descrizione riga |
InvoiceStatusRefreshWorker | BackgroundService di refresh stati ogni 10 minuti |
Helper statico di mapping:
| Helper | Ruolo |
|---|---|
InvoiceStatusExtensions | Mappa codici notifica SDI (RC, NS, ecc.) a InvoiceStatus |
InvoiceActionRunner | Pattern 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.
TipoDocumentodinamico: renderizzato dainvoice.documentTypeCode(non più fisso aTD01), quindi riflette qualsiasi tipo configurato ininv.documentTypes(TD01fattura,TD04nota di credito, ecc.).DatiFattureCollegate: emesso solo seRelatedDocumentNumberè valorizzato (fatture conrelatedInvoiceId, cioè le note di credito) — riportaIdDocumento(codice della fattura originale) ed eventualmenteData(data emissione originale).InvoiceServicepopola questi due campi risolvendoinvoice.relatedInvoiceId.DatiPagamentoopzionale: 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, DT | Accepted |
NS, MC | Discarded |
EC | Rejected |
SE | UndeliverableReceipt |
| (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
- Carica
invoicedal DB → lancia se non esiste. - Verifica che
InvoiceStatus == Draft→ lancia altrimenti. - Carica
issuer,company,lines,payments,vatCodes. - Valida che ci siano rate — tranne per le note di credito (
documentTypeCode == "TD04"), dove il piano rate è facoltativo e la validazione è saltata. - Costruisce
FatturaPaXmlInpute genera l'XML viaIFatturaPaXmlGenerator. - Chiama
IArubaSdiService.SendInvoiceAsync→ ottienetransmissionId. - Aggiorna
invoice:status = Sent,arubaTransmissionId = ...,sentXmlContent = xml,updatedAt = now. - Inserisce riga in
invoiceStatusHistory.
Ogni step fallisce fast con InvalidOperationException dal messaggio
descrittivo (catturato dal code-behind UI e mostrato via toast).
Flusso RefreshStatusFromArubaAsync
- Carica
invoice, verifica presenzaarubaTransmissionId. - Carica
issuer(per credenziali Aruba). - Chiama
IArubaSdiService.CheckStatusAsync(issuer, transmissionId). - Aggiorna stato invoice da
ArubaStatusResult(viaInvoiceStatusExtensions.FromArubaNotification). - Registra cambio in
invoiceStatusHistoryconarubaStatusgrezzo.
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:
InvoiceService.BuildCreditNoteHeader(source)costruisce l'header in memoria (vedi sopra) e apre il wizard fattura precompilato.- Al primo "Procedi" del wizard (
InvoiceFormPopup.SaveInvoiceDraft), la bozza viene inserita e, poichérelatedInvoiceIdè valorizzato,InvoiceBillingService.CopyLinesFromInvoiceAsynccopia le righe della fattura originale (vedi sopra). - Il piano rate resta facoltativo (passo Pagamenti del wizard skippabile).
- All'invio,
BuildXmlInputAsyncrisolveinvoice.relatedInvoiceIdper popolareRelatedDocumentNumber/RelatedDocumentDatenelFatturaPaXmlInput, da cuiFatturaPaXmlGeneratorgenera il nodoDatiFattureCollegatee ilTipoDocumento = 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— coreServices/Invoicing/IArubaSdiService.cs+ArubaSdiService.cs— integrazione HTTP ArubaServices/Invoicing/IFatturaPaXmlGenerator.cs+FatturaPaXmlGenerator.cs— generazione XML 1.2Services/Invoicing/InvoiceStatusExtensions.cs— mapping codici SDITrainingHub.UnitTests/Services/Invoicing/— 21 file di copertura unit test sull'area
🔌 Estensione tipica
Aggiungere un metodo al servizio
- Aggiungere signature in
IInvoiceService. - Implementare in
InvoiceService. - Scrivere test in
InvoiceServiceTests(con mock dei 3 dependency). - 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
-
SendToArubaAsyncmonolitico. 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. -
SavePaymentsAsyncmutates input. Side-effect non dichiarato nei parametri. Considerarerecord/immutable e restituire le nuove rate, o rendere esplicito nel nome (SaveAndAssignAsync). -
Nessun retry/backoff suC'è:IArubaSdiService.SendInvoiceAsync.ArubaSdiService.cs:53-56fa un solo retry — «oltre, l'errore deve arrivare all'utente» — attendendoex.RetryAfter ?? 5s; e:220-229traduce il 429 inArubaRateLimitExceptionrispettando l'headerRetry-After. Coperto daArubaSdiServiceRetryTests.cseArubaSdiServiceRateLimitTests.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. -
GetPaymentsAsyncinclusa inIInvoiceServicema è una pura query. Se la superficie dell'interfaccia cresce, valutare split (IInvoiceQueriesvsIInvoiceCommands).