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

FieldTypeRequiredNote
idstringVirtual derived from _id.
tenantIdstringyesMulti-tenant, indexed.
skustringyesUnique per {tenantId, sku}; normalized to UPPERCASE before saving.
statusenumnoSee status divergence below. Default 'draft'.
productTypeenumnoSee productType vs type below.
upcstringnoRegex /^\d{12}$/.
barcode / barcodeTypestring / enumno
categoryMap{domain, value}[]noDefault [].
conditionenumnoSee condition divergence below.
title / description / htmlDescription{value, translations[]}title yes, the rest noi18n fields.
brand / model / productKindstringno
satCodestringno8 digits, used for CFDI.
pricenumberyesmin: 0, indexed.
compareAtPricenumbernoValidated in pre('save'): if sent, it must be greater than price or the save fails.
priceSchemasProductPrice[]noSee sub-shapes.
cost / costsnumber / ProductCost[]noCost Layers.
inventoryCost / inventoryCostMethodnumber / enumnoDerived from the lot system; read-only.
categoryProductCategory (single object, not an array)noSee sub-shapes.
currencystring ISO-3noDefault 'USD'.
taxablebooleanyes (type)Default true.
mediaProductMedia[]yes (type)See sub-shapes.
variants / optionsarraysnoSee ProductVariant below.
attributesProductAttribute[]yes (type)
shippingProductShipping (embedded)no
seo{metaTitle, metaDescription, slug}noslug regex /^[a-z0-9-]+$/; metaTitle max 60, metaDescription max 160.
tags / bulletsstring[]nobullets max 7 (marketplace use).
bindingsProductBinding[]noSee channel divergence below.
bundleConfigBundleConfignoOnly when productType === 'bundle'. See Bundle.
minAlertStocknumbernoDefault 5.
sellIfOutOfStockbooleanyes (type)Default false.
embeddings / embeddingMetadataobjectsnoThe real schema has text/images/combined (v2.0) that do not exist in the ProductEmbeddings TS type.
fitmentConfigMixednoNo structural validation in Mongoose.
supplyConfig / materialConfig / billOfMaterials / modifiersConfigobjectsnoOnly apply depending on productType. See Materials / Supplies / BOM / Modifiers.
metafieldsMetafield[]noKnown 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:

  1. The library's PRODUCT_TYPES constant declares 3 values: simple | variable | bundle.
  2. The public TS type Product.type declares 5 values: simple | variable | bundle | supply | material.
  3. The real Mongoose schema has no type field at all. It only has productType, 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

FieldTypeRequiredNote
idstringnoAuto-generated (ObjectId().toString()).
skustringyesUnique at the product+tenant level; normalized to UPPERCASE.
parentSku / productSkustringnoAutomatically assigned to the parent product's SKU in pre('save').
imageIdstringnoReferences ProductMedia.id.
barcode / barcodeType / satCodenoVariant-level overrides.
productTypeenum physical | digital | servicenoDefault physical. See note above — this is not the same domain as Product.productType.
title{value, translations}yes
pricenumberyesmin: 0.
priceSchemas / cost / costsnoAnalogous to the product.
compareAtPricenumbernoNot validated against price at the variant level — the "must be greater" pre('save') rule only applies at the product level.
positionnumberyesmin: 0.
taxablebooleanyesDefault true.
shippingProductShipping (override)noIf empty, inherits from the product.
options{id, name, value}[]yes (type)id references product.options[i].id.
bindings{id?, channelId, handle}[]yes (type)
countryOfOriginstring ISO-2no
minAlertStocknumber | nullnoInherits from the parent product, or defaults to 5.
sellIfOutOfStockbooleanyesDefault 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 (default false), content: Mixed (req), currency (req, ISO-3), target: 'customers' | 'client' | 'segment' (req); channels/client/segment become conditionally required depending on target.
  • 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' (default image), 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 (default true).
  • 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, default string), 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] (default other), consumptionUnit (req, default pza), 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, methods fifo | lifo | average | last_purchase), expiration { expires, shelfLifeDays, expirationPolicy }, suppliers[], stockAlerts (req), lotTrackingEnabled.
  • BOM (billOfMaterials, applies to any product, not gated by productType): consumptionMode: 'on_sale' | 'on_production' | 'manual', yield { quantity, unit }, components[] (minimum 1, req; each one materialSku + quantity + unit, req, with substitutes[]), wastagePercent ≤ 100.
  • Modifiers (food-delivery style modifier groups — Uber Eats/Rappi/DiDi): enabled (default false), groups[] with code, name (req), selectionRule, modifiers[] (minimum 1, req; each one code, name, priceAdjustment { type: fixed|percentage|replace, value, currency }, channelBindings[] with channelType: 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.

Next steps