ADR-036: Invoicing line item source + product convention
Date: 2026-04-25 Authors: Jean-Francois Meyers Scope:
Granit.Invoicing,Granit.Invoicing.Abstractions,Granit.Invoicing.EntityFrameworkCore,Granit.Subscriptions(orchestrators),Granit.Metering(UsageSummaryReadyEto)
Context
Section titled “Context”Granit.Invoicing.InvoiceLineItem already carried a SourceType
(Subscription / Usage / OneShot / Credit) and an optional SourceId
(string?). In practice the field had no formal contract:
- The
Subscriptionorchestrators stored the subscription id as a Guid string when generating recurring lines, but nothing prevented a future caller from putting a plan price id, an external Stripe id, or free text. - The
Usagelines emitted byDefaultUsageInvoiceOrchestratorstored the meter definition id — again as a Guid string by convention only. - One-shot e-commerce purchases and manual credit lines were free-form by design (SKU codes, refund references, etc.).
Two consequences:
- Audit chain was broken in practice. ADR-032 declared the
event → metric → product → invoice linechain as a goal, but with no typed contract on(SourceType, SourceId)the only way to verify the chain in a SQL query was to assume the convention held and pray. ISO 27001 A.12.4 (logging and monitoring) wants this kind of traceability to be enforced, not implied. - Product attribution was lost across renames. When a meter was
renamed (
apicall_count→compute_units) or aPlanPricewas replaced by a newer version, the historical invoice lines kept their staleDescriptiontext. There was no stable identifier on the line pointing back to “the thing that was sold” — exactly the gap thatGranit.Catalog.Productwas created to close (ADR-032).
Phase 5 of EPIC #1155 (ORB-alignment) calls for closing both gaps in a
single small change: a typed convention on (SourceType, SourceId) and a
soft propagation of ProductId from the upstream entity.
Decision
Section titled “Decision”1. Typed convention on (SourceType, SourceId)
Section titled “1. Typed convention on (SourceType, SourceId)”SourceId is no longer free-form for the two billing source types. The
factory InvoiceLineItem.Create enforces the contract at the entity
boundary:
SourceType | SourceId contract |
|---|---|
Usage | Required. MUST be MeterDefinition.Id as a Guid string. |
Subscription | Required. MUST be Subscription.Id (or PlanPrice.Id) as a Guid string. |
OneShot | Free-form. May be a SKU, an external id, or null. |
Credit | Free-form. May be a refund reference, a credit memo number, or null. |
Failures throw ArgumentException synchronously — there is no in-flight
validation framework hop. OneShot and Credit stay free-form because
they cover heterogeneous data sources (e-commerce SKUs, manual
adjustments, third-party refund ids) where requiring a Guid would be
incorrect.
2. Optional ProductId on the line item
Section titled “2. Optional ProductId on the line item”InvoiceLineItem.ProductId (Guid?) is added as a soft reference to
Granit.Catalog.Product. The same soft-reference rules from ADR-032
apply: no SQL FK, no cross-module DbContext join, no integrity
enforcement at the EF layer. A non-clustered index supports
“show me all invoice lines for product X” reporting queries.
The orchestrators populate the field when they have it:
DefaultUsageInvoiceOrchestratorreads it from theUsageSummaryReadyEto, which the metering-sideBillingCycleUsagePublisherpopulates fromMeterDefinition.ProductId.DefaultBillingCycleInvoiceOrchestratorresolves it from the activePlanPricefor the subscription’s(currency, interval)slot — or from the override price pinned by an activeSubscriptionPhasewhen one applies.
When the upstream entity has no ProductId (no Granit.Catalog
installation, or a meter / price not yet linked to a product), the field
stays null. The change is fully additive.
3. UsageSummaryReadyEto carries MeterProductId
Section titled “3. UsageSummaryReadyEto carries MeterProductId”The contract change to the integration event is additive (new optional
property, defaults to null). Existing publishers that don’t know about
Granit.Catalog continue to publish events without it; existing
subscribers that don’t read it continue to work unchanged.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Audit chain is enforceable in SQL. A reviewer can write
SELECT * FROM invoice_line_items WHERE source_type = 'Usage' AND source_id = '<meter_id>'and trust that every matching row genuinely came from that meter — the factory rejects deviations. - Stable product attribution. Renaming a meter or replacing a plan
price no longer orphans historical invoice lines; the
ProductIdcolumn points to aGranit.Catalog.Productwhose own lifecycle (Draft / Published / Archived) is independent. - Cross-cutting reporting. “Revenue per product across subscriptions
and usage” becomes a single
GROUP BY product_id— previously impossible without joining stale name strings. - Index-supported lookups. The non-clustered index on
ProductIdmakes per-product invoice-line queries efficient even on multi-tenant databases.
Negative
Section titled “Negative”- A small breaking change at the entity boundary for callers that
passed a non-Guid
SourceIdforUsage/Subscription. The framework’s own orchestrators always passed Guids, so this is a formalization rather than a behavioral break — but downstream apps with custom orchestrators that violated the convention will now fail fast at construction. This is intentional: failing fast at the factory is preferable to silently storing untraceable lines. - The new optional field on
UsageSummaryReadyEtowidens the public contract ofGranit.Metering.Abstractions. Wolverine’s serializer handles the additional nullable property transparently in both directions; the change is wire-compatible with older versions of the consumer / publisher.
Non-goals
Section titled “Non-goals”- No FK enforcement on
ProductId. Cross-module FKs are explicitly rejected by ADR-032; the catalog deletion safety check stays in the application layer. - No retroactive backfill. Historical invoice lines created before
this change keep
ProductId = NULL. A backfill migration is left to consuming applications that want it. - No tightening of
OneShot/Credit. Both stay free-form. Any future tightening would belong in a separate ADR with concrete use cases on the table.
References
Section titled “References”- ADR-032 — Granit.Catalog with Product as the shared billing aggregate
- EPIC #1155 — ORB-alignment program (Phase 5)
- ISO 27001 A.12.4 — Logging and monitoring