Data model — Product and ProductVariant
This article describes the actually persisted shape of a product: the source is the Mongoose schema (Lib/Services/Products/src/model/schema.ts), not the public TypeScript type from the @fenicia/core package.
The public TS type and the real schema DIVERGE
The @fenicia/core package exposes a Product type broader than what Mongoose actually accepts to save. If you generate your client types from that package, you'll end up with fields the server never persists and enums the server rejects on save. This page documents the real contract; divergences are explicitly flagged in each row.
Product — main fields
| Field | Type | Required | Note |
|---|---|---|---|
id | string | — | Virtual derived from _id. |
tenantId | string | yes | Multi-tenant, indexed. |
sku | string | yes | Unique per {tenantId, sku}; normalized to UPPERCASE before saving. |
status | enum | no | See status divergence below. Default 'draft'. |
productType | enum | no | See productType vs type below. |
upc | string | no | Regex /^\d{12}$/. |
barcode / barcodeType | string / enum | no | |
categoryMap | {domain, value}[] | no | Default []. |
condition | enum | no | See condition divergence below. |
title / description / htmlDescription | {value, translations[]} | title yes, the rest no | i18n fields. |
brand / model / productKind | string | no | |
satCode | string | no | 8 digits, used for CFDI. |
price | number | yes | min: 0, indexed. |
compareAtPrice | number | no | Validated in pre('save'): if sent, it must be greater than price or the save fails. |
priceSchemas | ProductPrice[] | no | See sub-shapes. |
cost / costs | number / ProductCost[] | no | Cost Layers. |
inventoryCost / inventoryCostMethod | number / enum | no | Derived from the lot system; read-only. |
category | ProductCategory (single object, not an array) | no | See sub-shapes. |
currency | string ISO-3 | no | Default 'USD'. |
taxable | boolean | yes (type) | Default true. |
media | ProductMedia[] | yes (type) | See sub-shapes. |
variants / options | arrays | no | See ProductVariant below. |
attributes | ProductAttribute[] | yes (type) | |
shipping | ProductShipping (embedded) | no | |
seo | {metaTitle, metaDescription, slug} | no | slug regex /^[a-z0-9-]+$/; metaTitle max 60, metaDescription max 160. |
tags / bullets | string[] | no | bullets max 7 (marketplace use). |
bindings | ProductBinding[] | no | See channel divergence below. |
bundleConfig | BundleConfig | no | Only when productType === 'bundle'. See Bundle. |
minAlertStock | number | no | Default 5. |
sellIfOutOfStock | boolean | yes (type) | Default false. |
embeddings / embeddingMetadata | objects | no | The real schema has text/images/combined (v2.0) that do not exist in the ProductEmbeddings TS type. |
fitmentConfig | Mixed | no | No structural validation in Mongoose. |
supplyConfig / materialConfig / billOfMaterials / modifiersConfig | objects | no | Only apply depending on productType. See Materials / Supplies / BOM / Modifiers. |
metafields | Metafield[] | no | Known namespaces: mkt_category, mkt_attr, mkt_variant. |
TS type fields with no real persistence
These fields exist in the public @fenicia/core type but are not saved in the Mongoose document — don't rely on them if you generate your model from the TS type: inventoryQuantity, categoryString, attributeGroups, warranty, preparationTime, localization.
marketplaceCategories is not a stable field
marketplaceCategories exists in some documents but is deliberately commented out/disabled in the schema, with an explicit TODO from the team itself. Don't treat it as a supported field.
status — 3 real values, not 5
The Mongoose schema only accepts:
active | disabled | draft (default: draft)The public TS type declares 5 values: active | disabled | inactive | draft | out_of_stock. inactive and out_of_stock do not exist in the real enum — if your client tries to save a product with either of those two values, Mongoose rejects the save via enum validation.
condition — 12 real values, not 3
The public TS type declares only 3 values (the exact subset was not captured during this article's audit). The real Mongoose schema accepts 12: new, new-open-box, new-oem, refurbished, plus 4 variants grouped under the used-* prefix and 4 variants grouped under the collectible-* prefix (the exact suffixes of those 8 values were not captured during the audit — check the source schema if your integration depends on the literal value).
productType vs type — three definitions that don't match
There are three different places in the code that define "product type", and they don't match each other:
- The library's
PRODUCT_TYPESconstant declares 3 values:simple | variable | bundle. - The public TS type
Product.typedeclares 5 values:simple | variable | bundle | supply | material. - The real Mongoose schema has no
typefield at all. It only hasproductType, with a 6-value enum that mixes physical nature with composition structure:
physical | digital | service | bundle | supply | material'simple' and 'variable' are not persistable
Neither exists in the real document. At this field's level there is no runtime way to explicitly mark "product with variants" vs "simple product" — that distinction is inferred today from whether variants[] has elements, not from a type/productType field. If you document or integrate against the data model, use productType (the real one), not the TS type type.
Tip
ProductVariant also has a field called productType, but it is a different field with a different domain: only 3 values (physical | digital | service, default physical) — it does not mix bundle/supply/material. Don't assume a product's productType and its variant's share the same enum just because they share a name.
ProductVariant — main fields
| Field | Type | Required | Note |
|---|---|---|---|
id | string | no | Auto-generated (ObjectId().toString()). |
sku | string | yes | Unique at the product+tenant level; normalized to UPPERCASE. |
parentSku / productSku | string | no | Automatically assigned to the parent product's SKU in pre('save'). |
imageId | string | no | References ProductMedia.id. |
barcode / barcodeType / satCode | — | no | Variant-level overrides. |
productType | enum physical | digital | service | no | Default physical. See note above — this is not the same domain as Product.productType. |
title | {value, translations} | yes | |
price | number | yes | min: 0. |
priceSchemas / cost / costs | — | no | Analogous to the product. |
compareAtPrice | number | no | Not validated against price at the variant level — the "must be greater" pre('save') rule only applies at the product level. |
position | number | yes | min: 0. |
taxable | boolean | yes | Default true. |
shipping | ProductShipping (override) | no | If empty, inherits from the product. |
options | {id, name, value}[] | yes (type) | id references product.options[i].id. |
bindings | {id?, channelId, handle}[] | yes (type) | |
countryOfOrigin | string ISO-2 | no | |
minAlertStock | number | null | no | Inherits from the parent product, or defaults to 5. |
sellIfOutOfStock | boolean | yes | Default false. |
A variant's stock does not live in the document
There is no stock field inside ProductVariant. Stock is resolved via a live join against the separate inventory collection — consistent with the default projection (DEFAULT_PROJECTION) reserving variants.stock only for the in-memory result you see in the response, not for what's persisted in the product document.
Relevant sub-shapes
- Price (
ProductPriceSchema):id,name(req),isWholesale: boolean(defaultfalse),content: Mixed(req),currency(req, ISO-3),target: 'customers' | 'client' | 'segment'(req);channels/client/segmentbecome conditionally required depending ontarget. - Cost (
ProductCostSchema):id,name,type: 'money' | 'rate'(req),cost ≥ 0(req),currency(req),reason: enum['tax','delivery-packaging','fees','material','inventory','custom'](req). - Media (
ProductMediaSchema):id,src(regex^https?://, req),name(req),type: 'image' | 'video' | 'audio' | 'document'(defaultimage),size: Mixed,position ≥ 0(req),alt: TranslatedField(req),bindings[]. - Category (
ProductCategorySchema):id,name(req),channels[],mappings.claro/mappings.mercadoLibre.{mexico,argentina,colombia},path(req),selectable: boolean(defaulttrue). - SEO:
metaTitle(max 60) /metaDescription(max 160) /slug(lowercase, regex/^[a-z0-9-]+$/). - Metafields:
namespace,key(req, indexed),type: enum[string,json,number_integer,number_decimal,date,date_time,url,boolean](req, defaultstring),value: Mixed(req).
Binding — inconsistent channel enum
ProductBindingSchema: channelId, handle (req), type: enum[mercadolibre,shopify,amazon,walmart,shein,woocommerce,t1,claroshop,linio,coppel] (req), status/desiredStatus: enum['active','inactive','pending','error','paused'], syncStatus: enum['synced','pending','error'], plus reservedStock (default 0), overrides[], customValues[], and channel-specific data (mercadoLibreData, sheinData, coppelData, t1Channels[]).
Two channel-catalog bugs, not just one
The type enum in this sub-shape has 10 values — it's missing liverpool, which does exist in the global CHANNEL_TYPES catalog (11 values). It also uses the literal claroshop (no hyphen) while the global catalog uses claro-shop (with a hyphen) for the same channel. These are two different strings pointing to the same channel — a real risk that a literal comparison (===) fails silently. If your integration compares channel types, don't assume both catalogs are in sync.
Bundle (bundleConfig)
Not an HTTP entity — it's a data sub-shape
bundleConfig has no endpoints of its own (/products/{sku}/bundle does not exist). It is read and written only as part of the full payload of PUT /products/{id}. The only product-of-products composition mechanism with dedicated endpoints is BOM — see Materials and BOM.
Applies only when productType === 'bundle': inventoryMode: 'calculated' | 'reserved' (default calculated), components[] (minimum 1, req): productSku (req), variantMode: 'fixed' | 'selectable' | 'any' (default fixed), quantity ≥ 1 (req, default 1), required (default true).
Materials / Supplies / BOM / Modifiers
Sub-shapes outside the core Product/ProductVariant but part of the same document; their endpoints are in Materials and BOM.
- Supply (
productType: 'supply'):supplyCategory: enum[packaging,shipping,office,cleaning,tools,labels,other](defaultother),consumptionUnit(req, defaultpza),stockAlerts { minStock, reorderPoint, reorderQuantity (all ≥ 0, req), alertEmails[], alertEnabled (default true) },typicalUsagePerOrder,shippingAssociation,preferredSupplierId. - Material (
productType: 'material'):baseUnit { code, name, precision ≤ 6 }(req),alternativeUnits[],costing(req, methodsfifo | lifo | average | last_purchase),expiration { expires, shelfLifeDays, expirationPolicy },suppliers[],stockAlerts(req),lotTrackingEnabled. - BOM (
billOfMaterials, applies to any product, not gated byproductType):consumptionMode: 'on_sale' | 'on_production' | 'manual',yield { quantity, unit },components[](minimum 1, req; each onematerialSku + quantity + unit, req, withsubstitutes[]),wastagePercent ≤ 100. - Modifiers (food-delivery style modifier groups — Uber Eats/Rappi/DiDi):
enabled(defaultfalse),groups[]withcode,name(req),selectionRule,modifiers[](minimum 1, req; each onecode,name,priceAdjustment { type: fixed|percentage|replace, value, currency },channelBindings[]withchannelType: enum['uber-eats','rappi','didi-food','mercado-libre','shopify','other']),conditions { variantSkus[], availableHours { start, end } (regex HH:MM), availableDays[] }.
Material's public types are disabled in the package's exports
CreateMaterialInput and related types are commented out from @fenicia/core's public exports, with an explicit TODO ("temporarily disabled... to allow countAvailableProducts fix to deploy"). The functionality exists and works at runtime — the problem is that an external TypeScript consumer cannot import those types from the published package.