Features / supported
Product catalog
Purpose
Own products (with accountant-approved revenue-recognition policy), plans with immutable published versions, price components with validated pricing definitions, discounts, and product→ERP mappings. Aggregates keep stable team-scoped codes; content lives in immutable versions.
User outcomes
- A billing admin defines a product once; policy changes append product versions and never rewrite history (BC-US-011).
- Draft plan versions are freely edited and deletable; publication freezes them (database-enforced) and pins existing subscriptions to their assigned version (BC-US-013/014).
- Discounts are published as immutable versions and attached to a contract or subscription for an effective interval; deactivation is prospective only (BC-US-060/061/062).
Actors and permissions
Mutations: billing_admin or team_admin. ERP mapping writes:
finance_operator. Reads: every team role. All queries are constrained by
the scope’s team.
Domain terminology
- Recognition policy —
point_in_timeorover_time(+ mandatoryservice_period_sourcefor over-time), with approver evidence fields. - Pricing definition — a
schema_version: 1map for one of the seven pricing models (SPEC §9.6):fixed_recurring,one_time,standard_metered,volume_tier,graduated_tier,package,minimum_commit. Pure data, no floats (INV-006); rates/quantities are decimals, monetary amounts integer minor units. - Content hash — canonical SHA-256 of the published snapshot.
Workflows
create_product— inserts the product plus immutable version 1;update_productappends the next version and moves the head; the code becomes immutable once any price component references the product;deactivate_productblocks future publication, historical versions stay valid.create_plan→create_draft_plan_version(currency,interval_unitmonth/day,interval_count,billing_timingin_advance/in_arrears) →add_price_component/update_price_component/remove_price_component→publish_plan_version→ optionallyretire_plan_version.- Every
pricing_definitionis validated withBillingCore.Pricing.Model.from_map/1the moment it enters the catalog and stored in canonicalto_map/1form; publication re-validates all components. upsert_product_erp_mappingstores the ERP product number withvalidation_status: "pending"; provider validation is the ERP context’s job (BC-US-012).create_discount→publish_discount_version→assign_discount(exactly one of contract/subscription) →deactivate_assignment(prospective; never in the past, never extending the interval).
State transitions
Plan versions: draft → published → retired. Drafts are mutable and
deletable; the database permits only published → retired on published
rows — everything else is frozen. Products: active → inactive.
Discount assignments: active → deactivated.
Business rules / invariants
- Publication requires: at least one component; every component’s product active with its pinned product version present; every pricing definition parses; over-time components carry a service-period source (SPEC §9.4, also database-CHECKed).
- Whole-month interval counts are limited to 1/2/3/4/6/12 (SPEC §9.5).
- A price component has no currency of its own — amounts are denominated in the plan version’s currency.
- Component recognition defaults from the product’s policy; an explicitly overridden recognition mode never silently inherits the product’s service-period source.
- Publishing bumps
plans.current_version; existing subscriptions stay pinned to their assigned version. - Discount versions:
percentage(basis_points 1..10000) XORfixed_amount(positiveamount_minor+ currency); required priority andeffective_from;allocation_policydefaults to"proportional".
GraphQL contract
Queries product(s), plan, planVersion, discount; mutations
createProduct, createPlan, createPlanVersion (draft with components
in one command), publishPlanVersion, and mapProductToErp (BC-US-011 —
mirrors mapCustomerToErp; finance-role gated upsert of the ERP product
number for a connection).
CLI surface
Not yet implemented (BC-US-157 planned).
MCP surface
Not yet implemented (BC-US-158 planned).
UI behavior
LiveView surfaces under construction; domain commands available via GraphQL.
Accounting / ERP effects
Product ERP mappings connect catalog products to ERP product numbers (unique per team/connection/product; a number mapped to another product is a changeset error). The recognition policy on the published component drives accrual behavior downstream (see erp-synchronization).
Async / failure / recovery behavior
All catalog writes are synchronous transactions with row locks
(FOR UPDATE) on the owning aggregate. Publication failures roll back with
component-tagged reasons (e.g. {:product_inactive, code}).
Observability
Audit: catalog.product.*, catalog.plan_version.*,
catalog.price_component.*, catalog.discount*. Outbox:
product.version_created.v1, plan.version_published.v1,
discount.version_published.v1, discount.assignment_changed.v1.
Tests
test/workflows/catalog_publication_test.exs — the full draft/publish/
immutability/v2/pinning/retire journey, publication validation failures,
recognition inheritance, interval limits, authorization.
test/unit/pricing/ — pricing engine and discount math.
Security / privacy
Team-scoped queries everywhere; possession of a struct never grants access
(same_team check). No secrets in the catalog.
Limitations
- GraphQL
PricingDefinitionInputcurrently exposes onlyfixedRecurringandoneTime; the other five models are creatable through the Elixir context only. - No GraphQL surface yet for product updates/deactivation, draft edits, retirement, discounts, or ERP product mappings.