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.Builtin Self-hosted PDF via Granit.Templating (Phase 2)
    • Granit.Invoicing.Odoo External accounting sync via XML-RPC (Phase 2)
    • Granit.Invoicing.Notifications Email notifications for status changes (overdue, paid, …)

VAT calculation and VIES validation are delegated to the separate Tax module (Granit.Tax, Granit.Tax.Builtin, Granit.Tax.Stripe) — they ship as their own package family rather than as an Invoicing sub-package.

The lifecycle is driven by a Granit.Workflow finite-state machine (IWorkflowManager<InvoiceStatus>), which enforces transition validity, permissions, and the ISO 27001 audit trail on every state change.

stateDiagram-v2
    [*] --> Draft: Create()
    Draft --> Open: Finalize()
    Open --> Paid: RecordPayment() / ApplyCreditNote()
    Open --> Cancelled: Cancel()
    Open --> Uncollectible: MarkUncollectible()
    Uncollectible --> Paid: RecordPayment() (recovery)
    Uncollectible --> Cancelled: Cancel()
  • Draft: mutable — add line items, set billing address, adjust tax
  • Open: finalized, immutable financial fields, awaiting payment
  • Paid: payment received (terminal)
  • Cancelled: annulled before payment, invoice number preserved (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.

Subscription and Usage lines MUST carry a Guid SourceId referencing the originating entity:

SourceTypeSourceId
SubscriptionSubscription.Id (or PlanPrice.Id)
UsageMeterDefinition.Id
OneShotFree-form (SKU, external id, or null)
CreditFree-form (refund reference, credit memo, or null)

InvoiceLineItem.Create rejects non-Guid SourceId for the two billing source types — the audit chain event → metric → product → invoice line is SQL-queryable by construction.

InvoiceLineItem.ProductId (Guid?) is a soft reference to a Granit.Catalog.Product. The Subscriptions orchestrators populate it from PlanPrice.ProductId (Subscription line) or from MeterDefinition.ProductId forwarded via UsageSummaryReadyEto.MeterProductId (Usage line). The field survives meter / price renames and replacements, keeping invoice lines attributable to a stable catalog item for cross-cutting reporting.

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/{version}/invoicing/invoicesInvoicing.Invoices.Read
GET/api/{version}/invoicing/invoices/{id}Invoicing.Invoices.Read
GET/api/{version}/invoicing/invoices/{id}/pdfInvoicing.Invoices.Download
POST/api/{version}/invoicing/invoicesInvoicing.Invoices.Create
POST/api/{version}/invoicing/invoices/{id}/finalizeInvoicing.Invoices.Finalize
POST/api/{version}/invoicing/invoices/{id}/cancelInvoicing.Invoices.Cancel
POST/api/{version}/invoicing/invoices/{id}/mark-uncollectibleInvoicing.Invoices.MarkUncollectible

The transition endpoints (finalize, cancel, mark-uncollectible) delegate to IWorkflowManager<InvoiceStatus> before applying the domain method, so they return 409 Conflict when the target state is unreachable from the current one. POST /invoices/{id}/cancel takes an optional CancelInvoiceRequest { Reason } that is persisted on the workflow transition record for the audit trail.

Granit.Invoicing.Notifications defines 4 notification types:

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