Skip to content

Invoicing — Invoices, Credit Notes & Partial Payments

Granit.Invoicing manages the full invoice lifecycle from draft to paid, including credit notes, partial payments, and overpayment detection. It is agnostic — any module can create invoices via CreateInvoiceCommand, not just Subscriptions.

  • DirectoryGranit.Invoicing/ Domain: Invoice (unified with CreditNote), line items, tax, provider abstractions
    • Granit.Invoicing.EntityFrameworkCore EF Core store (SQL Server / PostgreSQL)
    • Granit.Invoicing.Endpoints Minimal API (invoice listing, PDF download) with RBAC
    • Granit.Invoicing CreateInvoiceHandler, payment event handlers
    • Granit.Invoicing.BackgroundJobs Overdue invoice scan
    • Granit.Invoicing.Internal Self-hosted PDF via Granit.Templating (Phase 2)
    • Granit.Invoicing.Odoo External accounting sync via XML-RPC (Phase 2)
    • Granit.Invoicing.EuVat EU VAT calculator with VIES validation (Phase 2)
stateDiagram-v2
    [*] --> Draft: Create()
    Draft --> Open: Finalize()
    Open --> Paid: RecordPayment() / ApplyCreditNote()
    Open --> Void: VoidInvoice()
    Open --> Uncollectible: MarkUncollectible()
    Uncollectible --> Paid: RecordPayment() (recovery)
    Uncollectible --> Void: VoidInvoice()
  • Draft: mutable — add line items, set billing address, adjust tax
  • Open: finalized, immutable financial fields, awaiting payment
  • Paid: payment received (terminal)
  • Void: cancelled with paper trail (terminal)
  • Uncollectible: bad debt write-off, can still recover

Invoices and credit notes share the same aggregate, differentiated by InvoiceDocumentType. Value Objects group related parameters:

  • BillingPeriod(Start, End) — billing period dates
  • CreditNoteInfo(ParentInvoiceId, Reason) — credit note link + reason
  • LineItemSource(Type, Id?) — agnostic source reference
// Regular invoice with billing period
var invoice = Invoice.Create(id, tenantId,
InvoiceDocumentType.Invoice, "EUR",
CollectionMethod.Auto, BillingReason.SubscriptionCycle,
period: new BillingPeriod(periodStart, periodEnd));
// Credit note (linked to parent invoice)
var creditNote = Invoice.CreateCreditNote(id, tenantId,
parentInvoiceId, "EUR", "Service interruption compensation");

When a credit note is finalized, CreditNoteIssuedEto is published instead of InvoiceFinalizedEto.

RecordPayment() accumulates payments rather than replacing the amount. Auto-transition to Paid occurs when AmountRemaining <= tolerance:

invoice.RecordPayment(10.00m, paidAt); // AmountPaid = 10, still Open
invoice.RecordPayment(19.99m, paidAt); // AmountPaid = 29.99, → Paid
// With tolerance (SEPA rounding)
invoice.RecordPayment(29.98m, paidAt, tolerance: 0.05m); // → Paid

ApplyCreditNote() enables Paid transition without any payment — critical for the event choreography (Subscriptions renews on InvoicePaidEto, not on PaymentSucceededEto):

invoice.ApplyCreditNote(29.99m, issuedAt); // AmountCredited = 29.99, → Paid

When AmountPaid + AmountCredited > Total, the Overpayment property tracks the excess and OverpaymentDetectedEto is published for reconciliation.

public enum InvoiceSourceType
{
Subscription, // Fixed plan charges
Usage, // Metered usage charges
OneShot, // One-time purchases
Credit, // Credit adjustments
}

This makes Invoicing agnostic — future Granit.Commerce can create invoices with OneShot line items using the exact same infrastructure.

InterfacePurposePhase
ITaxCalculatorTax computation per line item1 (stub)
IInvoiceSyncProviderExternal accounting sync (Odoo, Sage)2
IInvoiceDocumentGeneratorPDF generation via Granit.Templating2
IInvoiceNumberGeneratorGap-free sequential numbering with InvoiceDocumentType1 (interface)

Any module can create an invoice without referencing Invoicing directly:

var command = new CreateInvoiceCommand(
TenantId: tenantId,
Currency: "EUR",
CollectionMethod: CollectionMethod.Auto,
BillingReason: BillingReason.SubscriptionCycle,
LineItems: [
new("Pro Plan — Monthly", 1, 29.99m, InvoiceSourceType.Subscription),
new("API Calls: 15,000 requests", 15000, 0.001m, InvoiceSourceType.Usage),
],
PeriodStart: periodStart,
PeriodEnd: periodEnd);
await messageBus.SendAsync(command);

CreateInvoiceCommand accepts an optional IdempotencyKey to prevent duplicate invoice creation on handler retries:

new CreateInvoiceCommand(
...,
IdempotencyKey: $"billing-cycle-{subscriptionId}-{periodEnd:yyyyMMdd}");

Key derivation by handler:

HandlerKey format
BillingCycleCompletedHandlerbilling-cycle-{subscriptionId}-{periodEnd:yyyyMMdd}
UsageSummaryReadyHandlerusage-{tenantId}-{meterId}-{periodEnd:yyyyMMdd}

Business logic is encapsulated in domain services, keeping handlers as thin pass-through delegates.

InterfaceResponsibilityPackage
IInvoiceCreationServiceCreates draft invoices, calculates tax, generates numbers, finalizesGranit.Invoicing
IOverdueInvoiceDetectionServiceScans open invoices past due date, publishes InvoiceOverdueEtoGranit.Invoicing

IInvoiceCreationService is the domain entry point for CreateInvoiceCommand processing. It handles the full draft → finalize flow including optional ITaxCalculator and IInvoiceNumberGenerator integration:

public interface IInvoiceCreationService
{
Task CreateAsync(CreateInvoiceCommand command,
CancellationToken cancellationToken = default);
}

Both services are registered automatically by AddGranitInvoicing().

MethodRoutePermission
GET/api/granit/invoicing/invoicesInvoicing.Invoices.Read
GET/api/granit/invoicing/invoices/{id}Invoicing.Invoices.Read
GET/api/granit/invoicing/invoices/{id}/pdfInvoicing.Invoices.Download
POST/api/granit/invoicing/credit-notesInvoicing.CreditNotes.Manage

Granit.Invoicing.Notifications defines 4 notification types:

TypeNameSeverityOpt-out
InvoiceIssuedNotificationTypeInvoicing.InvoiceIssuedInfoYes
InvoiceOverdueNotificationTypeInvoicing.InvoiceOverdueWarningNo
InvoicePaidNotificationTypeInvoicing.InvoicePaidSuccessYes
CreditNoteIssuedNotificationTypeInvoicing.CreditNoteIssuedInfoYes