Repository: granit-fx/granit-dotnet
Author: jfmeyers
## Description
**Problem.** Metering and Subscriptions live in silos: a `MeterDefinition` (what is measured) and a `PlanPrice` (what is billed) have no shared point of attachment to the *thing being sold*. ORB's central concept `Item` (also `Product` in Stripe, `Material` in SAP) bridges both. Granit currently has nothing equivalent — and a `BillableItem` would be too narrow a name given future e-commerce ambitions.
**Solution.** Create a new module `Granit.Catalog` with `Product` as the aggregate root. It is referenced (nullable, no SQL FK across modules) by:
- `Granit.Metering.MeterDefinition.ProductId` — what this meter measures
- `Granit.Subscriptions.PlanPrice.ProductId` — what this price tarifs
- (future) `Granit.Catalog.Inventory`, `Granit.Catalog.Variants`, e-commerce facets
`Product` reuses `WorkflowLifecycleStatus` (Draft/Published/Archived) — same lifecycle as `Plan`.
**Naming rationale.** `Product` is the dominant term in e-commerce (Shopify, Magento, commercetools, Stripe, Zuora, Odoo all use it) and recognizable to non-technical roles (sales, marketing). `CatalogItem` was rejected as too generic; `Item` evokes "line item" (`InvoiceLineItem`) — risk of confusion in billing contexts.
**Dual-purpose scoping.**
- **MVP (Phase 1, this issue)**: Host → Tenant billing catalog. `Product` is Host-owned global (no `IMultiTenant`), mirroring `Plan`. Tenants reference these products through their subscriptions. SKUs unique within the Host catalog.
- **Future (e-commerce)**: same module, same `Product` aggregate, extended with multi-tenant scoping for Tenant → Customer e-commerce inventory. Path forward documented in ADR 032: either extend with `IMultiTenant` + custom dual-visibility filter, or introduce sibling `MerchantProduct` — to be chosen when e-commerce cadrage is done. Today's design forecloses neither.
**Alternatives considered.**
- *Name `Product` as `Item` (ORB convention)*: rejected — confusing with `InvoiceLineItem`, less recognizable to non-tech users, breaks alignment with Stripe (which is in Granit's module tree).
- *Name `Product` as `BillableItem`*: rejected — too narrow for future e-commerce, locks design to billing-only.
- *Place `Product` inside Subscriptions or Metering*: rejected — circular reference risk + naming traps.
- *Stay event-driven without explicit Product*: rejected — current convention-based matching (`UsageSummaryReadyEto.MeterName`) is implicit and fragile.
- *Make `Product` `IMultiTenant` from day 1*: rejected for MVP — current `IMultiTenant` filter is strict equality (excludes Host-owned rows from tenant queries) which would break the "tenant browses Host catalog" use case. Fixing the filter today would lock us into a design before e-commerce is cadred.
## User Stories
- #1162 - Scaffold Granit.Catalog module with Product aggregate
- #1163 - Add Product.ProductId reference to Granit.Metering MeterDefinition
- #1164 - Add Product.ProductId reference to Granit.Subscriptions PlanPrice
## Expected deliverables
**New module — 3 packages**
- [ ] `Granit.Catalog` — domain + DI extension + Module class
- [ ] `Granit.Catalog.Endpoints` — HTTP routes + DTOs + validators + permissions
- [ ] `Granit.Catalog.EntityFrameworkCore` — DbContext + EF configurations + migrations
**Domain**
- [ ] `Product` aggregate root (`AuditedAggregateRoot`, `IWorkflowStateful`)
- Fields: `Sku` (unique per tenant), `Name`, `Description?`, `Type` (Service/Metered/Physical/Digital), `Unit`, `Status` (`WorkflowLifecycleStatus`), `Metadata`, `ExternalMappings`
- Behavior methods: `Create`, `Update`, `Publish`, `Archive`, `AddExternalMapping`, `RemoveExternalMapping`
- [ ] `ProductExternalMapping` entity (Stripe/Avalara/Odoo codes — pattern repris de `PlanExternalMapping`)
- [ ] `ProductType` enum
**Endpoints** (route prefix `catalog`, OpenAPI tag `Catalog - Products`)
- [ ] `GET /catalog/products` (paginated via QueryEngine, status filter)
- [ ] `G…
Source: GITHUB