Catalog
The catalog module exposes both command handlers and REST endpoints under /api/catalog/* (products, variants, prices, price kinds, option schemas, offers, categories, tags, product media, and dictionaries). The commands documented below remain the source of truth for validations and undo metadata, so you can safely script imports, seed data, or build bespoke HTTP wrappers even as new HTTP routes come online.
REST endpoints
Every REST route described below lives in packages/core/src/modules/catalog/api. They share a few guarantees:
- Authentication and RBAC checks are enforced via each route's
metadataexport. - Organization/tenant scope is derived from the caller, so payloads never cross tenants.
- CRUD handlers reuse the same Zod schemas as the command bus, so keeping one payload up to date keeps both access paths in sync.
- Listing endpoints default to
{ items, total, page, pageSize, totalPages }responses and accept bothcf_<key>andcf:<key>filters emitted by the custom-field engine.
/api/catalog/products
Permissions — GET requires catalog.products.view; POST/PUT/DELETE require catalog.products.manage.
Filters
| Param | Type | Purpose |
|---|---|---|
page, pageSize | number | Pagination with a max of 100 rows per page. |
search | string | Case-insensitive match on title, subtitle, SKU, handle, or description. |
status, isActive, configurable | string | Filter by workflow status, activation, or configurability flags. |
productType | enum | Narrow the list to one of the CATALOG_PRODUCT_TYPES. |
channelId, channelIds, offerId | string | Restrict products bound to a specific channel/offer. |
categoryIds, tagIds | string | Comma-separated IDs for taxonomy filters. |
userId, userGroupId, customerId, customerGroupId | string | Scope best-price resolution to a specific audience. |
quantity, priceDate | number/string | Provide context for the pricing service (quantity tiers + date-based prices). |
sortField, sortDir | string | Sort by title, SKU, createdAt, or updatedAt. |
withDeleted | boolean | Include soft-deleted products. |
customFieldset | string | Ensure the backend interprets cf_* filters using the selected fieldset. |
The resolver decorates each item with:
offers: channel-level offers resolved fromCatalogOffer.pricing: the return value ofcatalogPricingService.resolvePrice, including the matching price kind, amounts, and scope (variant/offer/channel/audience IDs).- Flattened custom-field values (standard keys only — raw
cf:keys are stripped).
Mutations — POST and PUT accept exactly the shapes validated by productCreateSchema/productUpdateSchema. DELETE requires a payload with { id } and delegates to catalog.products.delete, performing a soft delete.
/api/catalog/variants
Permissions — GET requires catalog.products.view; writes require catalog.variants.manage.
Filters
| Param | Type | Purpose |
|---|---|---|
page, pageSize | number | Pagination (≤100 per page). |
search | string | Match variant name, sku, or barcode. |
productId, sku | string | Filter to a specific product or exact SKU. |
isActive, isDefault, withDeleted | string/boolean | Toggle core flags or include soft-deleted rows. |
sortField, sortDir | string | Sort by name, sku, createdAt, or updatedAt. |
cf_* | string | Apply custom-field filters (scoped to the caller's tenant). |
Responses include option values, dimensions, metadata, and flattened custom fields. POST/PUT payloads must satisfy variantCreateSchema/variantUpdateSchema; DELETE accepts { id } and calls catalog.variants.delete.
/api/catalog/prices
Permissions — GET requires catalog.products.view; mutations require catalog.pricing.manage.
Filters
| Param | Type | Purpose |
|---|---|---|
page, pageSize | number | Pagination. |
productId, variantId, offerId | string | Scope prices tied to a specific record. |
channelId, currencyCode | string | Filter by channel or ISO currency code. |
priceKindId, kind | string | Restrict by price kind or price kind code. |
userId, userGroupId, customerId, customerGroupId | string | Filter by personalized price audiences. |
withDeleted | boolean | Include rows even if the associated product/variant was soft deleted. |
sortField, sortDir | string | Sort by currency, price kind, quantity band, createdAt, or updatedAt. |
cf_* | string | Custom-field filters/ordering helpers. |
POST/PUT reuse priceCreateSchema/priceUpdateSchema so the HTTP route mirrors the command bus. DELETE forwards { id } to catalog.prices.delete. Responses expose raw catalog_product_price columns plus any associated custom fields.
/api/catalog/price-kinds
Permissions — all methods require catalog.settings.manage.
Filters
| Param | Type | Purpose |
|---|---|---|
page, pageSize | number | Pagination. |
search | string | Case-insensitive match on code or title. |
isPromotion, isActive | string | Filter by promotion/activation flags (true/false). |
sortField, sortDir | string | Sort by code, title, display mode, currency, createdAt, or updatedAt. |
POST and PUT translate inputs through priceKindCreateSchema/priceKindUpdateSchema and return { id }/{ ok: true }. DELETE expects { id } and soft deletes the price kind.
/api/catalog/option-schemas
Permissions — GET requires catalog.products.view; writes require catalog.settings.manage.
Filters
| Param | Type | Purpose |
|---|---|---|
page, pageSize | number | Pagination. |
id | string | Fetch a specific schema template. |
search | string | Match name or description. |
isActive, withDeleted | string/boolean | Filter by activation or include soft-deleted templates. |
cf_* | string | Apply custom-field filters stored on option schemas. |
Responses surface the saved schema JSON alongside metadata. POST/PUT wrap optionSchemaTemplateCreateSchema/optionSchemaTemplateUpdateSchema; DELETE forwards { id } to catalog.optionSchemas.delete.
/api/catalog/offers
Permissions — all methods require sales.channels.manage because offers tie catalog products to sales channels.
Filters
| Param | Type | Purpose |
|---|---|---|
page, pageSize | number | Pagination. |
productId, id | string | Limit offers for a given product or offer. |
channelId, channelIds | string | Filter by one channel or a comma-separated list. |
search | string | Match offer title/description. |
isActive, withDeleted | string/boolean | Restrict by activation or include soft-deleted offers. |
sortField, sortDir | string | Sort by title, createdAt, or updatedAt. |
List responses embed:
product: a lightweight snapshot (id,title, default media, SKU).prices: prices attached directly to the offer (with price kind metadata).productChannelPrice: fallback product-level price for the requested channel.- Flattened custom-field values.
POST/PUT feed offerCreateSchema/offerUpdateSchema; DELETE resolves { id } through catalog.offers.delete.
/api/catalog/categories
Permissions — GET requires catalog.categories.view; writes require catalog.categories.manage.
GET accepts:
| Param | Type | Purpose |
|---|---|---|
view | enum (manage/tree) | manage returns paginated rows flattened from the tree; tree returns nested nodes. |
page, pageSize | number | Pagination (manage view only). |
search | string | Match category names or breadcrumb labels. |
status | enum (all/active/inactive) | Filter by activation flag. |
ids | string | Comma-separated category IDs to focus on specific nodes. |
manage view rows include hierarchy metadata (depth, parent, child counts, breadcrumb labels) plus any custom-field values resolved via loadCustomFieldValues. tree view collapses the same hierarchy into nested children[].
POST, PUT, and DELETE route payloads through categoryCreateSchema, categoryUpdateSchema, and catalog.categories.delete respectively.
/api/catalog/tags
Permissions — GET requires catalog.products.view.
Query parameters: search, page, and pageSize. The handler enforces organization scope and returns { items, total }, where each item exposes id, label, slug, createdAt, and updatedAt.
/api/catalog/product-media
Permissions — GET requires catalog.products.view.
Query: productId (UUID, required). The route validates scope, loads attachment rows for the product, and returns { items: [{ id, fileName, url, thumbnailUrl }] }. Thumbnails are precomputed via buildAttachmentImageUrl for UI galleries.
/api/catalog/dictionaries/:key
Permissions — GET requires catalog.products.manage.
Path parameter :key accepts currency, currencies, unit, units, or measurement_units (aliases are normalized). The route resolves the matching dictionary for the caller's organization/tenant and returns { id, entries: [{ id, value, label, color, icon }] }. Use it to hydrate selectors that need shared catalog dictionaries without duplicating the dictionaries module API.
Running catalog commands
import { createRequestContainer } from '@/lib/di/container'
import type { CommandBus } from '@saasframe/shared/lib/commands'
const container = await createRequestContainer()
const commandBus = container.resolve<CommandBus>('commandBus')
await commandBus.execute('catalog.products.create', {
input: {
organizationId,
tenantId,
name: 'Starter T-Shirt',
code: 'tee-basic',
primaryCurrencyCode: 'USD',
metadata: { category: 'apparel' },
customFields: { material: 'cotton' }
},
ctx: { auth, container }
})
- Every command enforces tenant and organization scope via
ensureTenantScope/ensureOrganizationScope(packages/core/src/modules/catalog/commands/shared.ts:42). - Custom fields can be supplied either as
customFieldsobject or prefixed keys (cf_color); handlers callparseWithCustomFieldsandsetCustomFieldsIfAnyto persist values (packages/core/src/modules/catalog/commands/products.ts:100). - Undo metadata is logged automatically; the audit log payload includes before/after snapshots for δ previews and rollbacks.
Product commands — catalog.products.*
Handlers live in packages/core/src/modules/catalog/commands/products.ts.
| Command id | Purpose | Schema |
|---|---|---|
catalog.products.create | Create a product master record | productCreateSchema (packages/core/src/modules/catalog/data/validators.ts:18) |
catalog.products.update | Patch an existing product (only provided keys are updated) | productUpdateSchema (packages/core/src/modules/catalog/data/validators.ts:32) |
catalog.products.delete | Soft delete a product once variants/prices are cleaned up | n/a (requires before.id in the undo snapshot) |
Key fields from productCreateSchema:
organizationId,tenantId— required scope identifiers.name(string, ≤255), optionaldescription.- Optional catalog metadata:
code(lowercase slug),statusEntryId,primaryCurrencyCode,defaultUnit,attributeSchemaIdreference,attributeSchemaJSON override,attributeValuesmap, andoffers(array of{ channelId, title, description }) that bind products to sales channels. WhenattributeSchemaIdis provided, the product inherits a shared schema template; providingattributeSchemain the same payload overwrites individual fields for that single product without mutating the shared template. defaultMediaId/defaultMediaUrl— optional attachment handle + its SEO-friendly relative image URL for the primary product media. Upload images via/api/attachments, then resolve responsive URLs withbuildAttachmentImageUrl(id, { width, height, slug })or by calling/api/attachments/image/{id}/{slug}directly for server-side resizing.- Flags:
isConfigurable,isActive, plus arbitrarymetadataJSON.
Example payload:
{
"organizationId": "3d552375-7813-4e9d-8807-0283c907a9f9",
"tenantId": "a2c6e50e-a6ef-4c7a-afe4-7b6f1998b5c2",
"name": "Starter T-Shirt",
"code": "tee-basic",
"primaryCurrencyCode": "USD",
"attributeSchemaId": "edc95a4d-9fb9-4cbc-b8ce-1b93af2c8e83",
"attributeSchema": {
"definitions": [
{ "key": "material", "label": "Material", "kind": "text" }
]
},
"offers": [
{ "channelId": "bf5efc35-c493-44e4-9307-5f50331a7405", "title": "Online store" }
],
"isConfigurable": true,
"metadata": { "category": "apparel" },
"customFields": { "material": "cotton" }
}
Variant commands — catalog.variants.*
Defined in packages/core/src/modules/catalog/commands/variants.ts.
| Command id | Purpose | Schema |
|---|---|---|
catalog.variants.create | Create a SKU tied to a product | variantCreateSchema (packages/core/src/modules/catalog/data/validators.ts:39) |
catalog.variants.update | Patch variant attributes, dimensions, or option selections | variantUpdateSchema (packages/core/src/modules/catalog/data/validators.ts:65) |
catalog.variants.delete | Remove a variant after ensuring there are no prices or option links | n/a (id provided through undo payload) |
Highlights from variantCreateSchema:
- Required:
organizationId,tenantId,productId. - Optional:
name,sku,barcode,statusEntryId,isDefault,isActive. - Dimensions and weight via nested objects (
dimensions.width/height/depth/unit,weightValue,weightUnit). optionValuesmap keyed by option code to capture selections directly on the variant payload (also extracted frommetadata.optionValuesfor backward compatibility).
Price commands — catalog.prices.*
Stored in packages/core/src/modules/catalog/commands/prices.ts.
| Command id | Purpose | Schema |
|---|---|---|
catalog.prices.create | Add a price row for a variant | priceCreateSchema (packages/core/src/modules/catalog/data/validators.ts:273) |
catalog.prices.update | Update price attributes | priceUpdateSchema (packages/core/src/modules/catalog/data/validators.ts:298) |
catalog.prices.delete | Remove a price entry | n/a |
Key fields:
variantId,organizationId,tenantId, andpriceKindId.currencyCode(ISO-4217) with optional price kind currency override.- Quantity bands:
minQuantity,maxQuantity. - Price components:
unitPriceNet,unitPriceGross,taxRate. - Effective dates:
startsAt,endsAt(coerced toDateby Zod).
Shared tax calculation service
All price commands and the admin builders call the DI-provided taxCalculationService (registered by the Sales module) to keep net/gross/tax amounts consistent. The default implementation looks up the requested tax class (taxRateId) or explicit rate, normalizes non-negative inputs, and stores computed unitPriceNet, unitPriceGross, and taxAmount back on the catalog_product_prices row.
You can override/extend the calculation pipeline via the event bus:
sales.tax.calculate.before— receives{ input, setInput(next), setResult(result) }. CallsetInput()to tweak the pending payload (e.g., swap thetaxRateIdbased on the customer/region) orsetResult()to bypass the built-in calculator entirely.sales.tax.calculate.after— receives{ input, result, setResult(next) }. Use this to post-process the computed numbers (rounding, recoverable VAT adjustments, etc.).
Example hook (run from any module bootstrap/registrar):
import { createRequestContainer } from '@/lib/di/container'
const container = await createRequestContainer()
const eventBus = container.resolve('eventBus')
eventBus?.on('sales.tax.calculate.before', ({ input, setInput }) => {
if (!input.taxRateId && input.organizationId === 'org-brazil') {
setInput({ taxRateId: 'default-brazilian-rate' })
}
})
eventBus?.on('sales.tax.calculate.after', ({ result, setResult }) => {
if (result && result.taxAmount > 0) {
setResult({ ...result, taxAmount: Math.ceil(result.taxAmount * 100) / 100 })
}
})
Prefer taxRateId over raw percentages so the service can validate scope and reuse the Sales → Tax Rates admin UI.
Price kind commands — catalog.priceKinds.*
Stored in packages/core/src/modules/catalog/commands/priceKinds.ts.
| Command id | Purpose | Schema |
|---|---|---|
catalog.priceKinds.create | Register a reusable price kind | priceKindCreateSchema (packages/core/src/modules/catalog/data/validators.ts:258) |
catalog.priceKinds.update | Adjust labels, display mode, currency, or promotion flags | priceKindUpdateSchema (packages/core/src/modules/catalog/data/validators.ts:271) |
catalog.priceKinds.delete | Soft delete a price kind after ensuring no prices reference it | n/a |
Custom field fieldsets
Catalog products and variants no longer ship their own “attribute schema” concept. Instead they rely on the shared custom-fields engine and the CustomFieldEntityConfig fieldset metadata that is edited from Entities → System entities → catalog.catalog_product (and catalog.catalog_product_variant). Compose as many fieldsets as you need—e.g. fashion, equipment, services—and assign custom fields plus per-field groups within each set. The generated UI automatically scopes the editors, CRUD forms, and filters to the currently selected fieldset.
Key details:
- Fieldset metadata lives in
custom_field_entity_configsand is exposed through/api/entities/definitions?entityId=…. The request accepts?fieldset=<code>to filter definitions server-side; the admin UI calls this automatically when the user switches fieldsets. - Products (
catalog_products) and variants (catalog_product_variants) persist the chosen fieldset in thecustom_fieldset_codecolumn. Awilix commands and CRUD handlers already accept acustomFieldsetCodeproperty so you can capture that selection from bespoke flows. - Listing APIs allow a
customFieldset=<code>query parameter so filters and the query engine interpretcf_*filters using the right definition set. The backend usesbuildCustomFieldFiltersFromQuery(..., { fieldset })to scope validation and coercion. - Because we now reuse custom fields, all of the generic helpers (
collectCustomFieldValues,crud.create/updatepayloads, entity definitions UI) apply without a bespoke schema layer. Old attribute schema endpoints/tests have been removed; the existing migrations (up toMigration20251116183727) take care of dropping the template tables and extra JSON columns.
Customising price selection
All API surfaces that need “best price” calculations resolve the DI token catalogPricingService, which wraps resolveCatalogPrice() from packages/core/src/modules/catalog/lib/pricing.ts. The helper emits catalog.pricing.resolve.before and catalog.pricing.resolve.after events and walks a prioritized hook registry, falling back to the built-in layered selection (selectBestPrice) when no hook handles the request.
You can plug in custom logic (e.g., loyalty tiers, partner overrides) by registering a resolver from any module initialization file:
import { registerCatalogPricingResolver } from '@saasframe/core/modules/catalog/lib/pricing'
registerCatalogPricingResolver(
async (rows, ctx) => {
// rows: all matching CatalogProductPrice rows (with populated offer/variant/product)
// ctx: PricingContext (channelId, userId, quantity, date, etc.)
const partnerPrice = rows.find((row) => row.metadata?.partner === ctx.customerId)
if (partnerPrice) return partnerPrice
return undefined // fall back to the next resolver / default algorithm
},
{ priority: 100 },
)
- Resolvers run in descending priority order (default is
0); returnundefinedto defer to the next handler. - Returning
nullstops the pipeline and instructs callers that no price applies. - The default resolver (
selectBestPrice) still runs last so existing behaviour remains unless you override it. - The DI service automatically injects the app’s event bus; listeners can override prices by handling
catalog.pricing.resolve.before|after(each payload exposesrows,context,result, andsetResult, mirroring the sales calculation hooks). - Swap the pricing implementation entirely by registering your own
catalogPricingServicevia DI if you need to replace the pipeline wholesale.
Feature flags
RBAC gates for REST/UX surfaces are declared in packages/core/src/modules/catalog/acl.ts:1:
catalog.products.view/catalog.products.managecatalog.variants.managecatalog.pricing.managecatalog.settings.manage
Grant these to API keys or roles you intend to use with wrapper endpoints so future upgrades continue to respect access boundaries.
You can call the command bus directly or go through the REST endpoints above—the schemas listed here ensure imports, integrations, and tests remain aligned with the core module.