Materials, Supplies, and Bill of Materials (BOM)

This domain covers four related entities that support manufacturing, costing, and product preparation: materials (inputs with lot-based costing, used in production), supplies (supplies, operational consumables like packaging or labels), bill of materials (BOM — which materials a product consumes when sold or produced), and modifiers (customization options for food-delivery verticals like Uber Eats, Rappi, or DiDi Food).

There is no HTTP 'bundle' entity

The only product-of-products composition mechanism with its own endpoints is the bill of materials (BOM), oriented toward manufacturing and inventory consumption. A "bundle" or sales kit (bundleConfig) exists as a sub-object within the product payload (PUT /products/{id}, see Update a product) — it does not have its own /products/{sku}/bundle routes. If you're looking to expose/sell a kit made up of several products, that's bundleConfig, not BOM.

Materials, supplies, BOM, and modifiers are all persisted on the same Product model: a material is a Product with productType:'material' and its specific configuration in materialConfig; a supply has productType:'supply' and supplyConfig; the bill of materials lives in billOfMaterials on any product; modifiers live in modifiersConfig. These are not separate collections.

Materials

Materials live in a dedicated collection, not in materialConfig

Unlike what the conceptual model (§ above) suggests, the real MaterialsService code persists materials in a dedicated materials collection (not in the Product document with productType:'material' + materialConfig). That's why the object returned by these endpoints is flat: fields (baseUnit, costing, stockAlerts, etc.) sit at the document's root level, not nested under materialConfig. The data model table below describes the embedded field that does exist in the Product schema, but it is not what this REST surface actually returns.

Lists materials with pagination and filters

pagenumber

Page, 0-based. Default 0.

limitnumber

Items per page. Default 50.

categorystring

Filter by material category.

statusstring

Filter by material status.

termstring

Text search.

200
{
  "data": [
    {
      "sku": "MAT-TELA-ALGODON",
      "title": { "value": "Tela de algodón" },
      "baseUnit": { "code": "m", "name": "Metro", "precision": 2 },
      "costing": {
        "method": "fifo",
        "currentCost": { "value": 45.5, "currency": "MXN", "perUnit": "m", "calculatedAt": "2026-07-21T12:00:00.000Z" }
      },
      "stockAlerts": { "minStock": 20, "reorderPoint": 30, "reorderQuantity": 100, "alertEnabled": true },
      "lotTrackingEnabled": true,
      "status": "active"
    }
  ],
  "total": 1,
  "page": 0,
  "limit": 50,
  "totalPages": 1,
  "hasMore": false
}

Required permission: products:read

Gets a material by SKU

skustringrequired

Material SKU.

404
{ "code": "not-found", "message": "Material not found" }

Required permission: products:read

Creates a material

{
  "sku": "MAT-TELA-ALGODON",
  "title": { "value": "Tela de algodón" },
  "baseUnit": { "code": "m", "name": "Metro", "precision": 2 },
  "costingMethod": "fifo",
  "initialCost": 45.5,
  "currency": "MXN",
  "lotTrackingEnabled": true,
  "stockAlerts": { "minStock": 20, "reorderPoint": 30, "reorderQuantity": 100, "alertEnabled": true }
}
201
{
  "sku": "MAT-TELA-ALGODON",
  "title": { "value": "Tela de algodón" },
  "baseUnit": { "code": "m", "name": "Metro", "precision": 2 },
  "alternativeUnits": [],
  "costing": {
    "method": "fifo",
    "currentCost": { "value": 45.5, "currency": "MXN", "perUnit": "m", "calculatedAt": "2026-07-21T12:00:00.000Z" },
    "costHistory": [{ "cost": 45.5, "currency": "MXN", "date": "2026-07-21T12:00:00.000Z", "quantity": 0 }]
  },
  "price": 45.5,
  "currency": "MXN",
  "expiration": { "expires": false, "expirationPolicy": "none" },
  "suppliers": [],
  "stockAlerts": { "minStock": 20, "reorderPoint": 30, "reorderQuantity": 100, "alertEnabled": true },
  "lotTrackingEnabled": true,
  "tags": [],
  "status": "active"
}
400
{ "code": "duplicate", "message": "Material with SKU MAT-TELA-ALGODON already exists" }

Required permission: products:create

Updates a material (partial)

skustringrequired

Material SKU.

{
  "stockAlerts": { "minStock": 25, "reorderPoint": 40, "reorderQuantity": 120, "alertEnabled": true },
  "lotTrackingEnabled": true
}
200
{
  "sku": "MAT-TELA-ALGODON",
  "title": { "value": "Tela de algodón" },
  "baseUnit": { "code": "m", "name": "Metro", "precision": 2 },
  "stockAlerts": { "minStock": 25, "reorderPoint": 40, "reorderQuantity": 120, "alertEnabled": true },
  "lotTrackingEnabled": true,
  "status": "active"
}
404
{ "code": "not-found", "message": "Material not found" }

Required permission: products:update

Deletes a material

skustringrequired

Material SKU.

200
{ "success": true, "message": "Material deleted" }
404
{ "code": "not-found", "message": "Material not found" }

Required permission: products:delete

Material Inventory

Total material stock (all locations)

skustringrequired

Material SKU.

200
{
  "totalQuantity": 340,
  "totalReserved": 15,
  "totalCommitted": 10,
  "totalAvailable": 315,
  "byLocation": [
    { "locationId": "65f2a0b1c4d5e6f7a8b9c0aa", "quantity": 340, "available": 315 }
  ]
}

Required permission: products:read

Material stock at a location

skustringrequired

Material SKU.

locationIdstringrequired

Location ID.

200
{
  "tenantId": "65e1f0a1c4d5e6f7a8b9c0bb",
  "materialSku": "MAT-TELA-ALGODON",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 340,
  "unit": "m",
  "reservedQty": 15,
  "committedQty": 10,
  "lotTrackingEnabled": true,
  "lots": [
    { "lotNumber": "L-2026-001", "quantity": 340, "costPerUnit": 45.5, "currency": "MXN", "receivedDate": "2026-06-01T00:00:00.000Z", "status": "available" }
  ],
  "minStock": 20,
  "reorderPoint": 30
}
404
{ "code": "not-found", "message": "Inventory not found for this material at this location" }

Required permission: products:read

Records or adjusts a material's inventory at a location

skustringrequired

Material SKU.

locationIdstringrequired

Location where inventory is recorded.

quantitynumberrequired

Quantity. Must be greater than 0.

unitstring

Unit of measure.

lotobject

Lot data, when the material has lot tracking enabled (lotTrackingEnabled): { lotNumber, costPerUnit?, currency?, expirationDate? }.

minStocknumber

Minimum alert stock (only when creating the inventory record).

reorderPointnumber

Reorder point (only when creating the inventory record).

{
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 100,
  "unit": "m",
  "lot": { "lotNumber": "L-2026-002", "costPerUnit": 46.0, "currency": "MXN" }
}
200
{
  "tenantId": "65e1f0a1c4d5e6f7a8b9c0bb",
  "materialSku": "MAT-TELA-ALGODON",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 440,
  "unit": "m",
  "reservedQty": 15,
  "committedQty": 10,
  "lotTrackingEnabled": true,
  "lots": [
    { "lotNumber": "L-2026-001", "quantity": 340, "costPerUnit": 45.5, "currency": "MXN", "receivedDate": "2026-06-01T00:00:00.000Z", "status": "available" },
    { "lotNumber": "L-2026-002", "quantity": 100, "costPerUnit": 46.0, "currency": "MXN", "receivedDate": "2026-07-21T12:00:00.000Z", "status": "available" }
  ]
}
400
{ "code": "bad-request", "message": "Quantity must be a positive number", "parameter": "quantity" }

Required permission: inventory:update

Transfers material inventory between locations

materialSkustringrequired

SKU of the material to transfer.

fromLocationIdstringrequired

Source location.

toLocationIdstringrequired

Destination location.

quantitynumberrequired

Quantity to transfer. Must be greater than 0.

{
  "materialSku": "MAT-TELA-ALGODON",
  "fromLocationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "toLocationId": "65f2a0b1c4d5e6f7a8b9c0cc",
  "quantity": 50
}
200
{ "success": true, "message": "Transfer completed" }
400
{ "code": "transfer-failed", "message": "Insufficient stock at source location. Available: 20" }

Required permission: inventory:transfer

Material Consumption

These three endpoints let you simulate and execute the consumption of materials a product needs when sold or prepared — typically invoked by the sales flow (POS, checkout) before or during order confirmation.

Checks material availability to produce/sell a quantity of a product, without consuming

productSkustringrequired

SKU of the product being evaluated (the one with an associated BOM).

locationIdstringrequired

Location where material stock is checked.

quantitynumber

Quantity of the product to produce/sell. Default 1.

selectedModifiersarray

Selected modifiers, if the product uses modifiers with associated consumption: [{ groupId, modifierId, quantity? }].

{
  "productSku": "PROD-CAMISA-M",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 2
}
200
{
  "available": true,
  "calculation": {
    "components": [{ "materialSku": "MAT-TELA-ALGODON", "quantity": 3, "unit": "m", "source": "bom" }],
    "totalMaterials": 1,
    "byMaterial": [{ "materialSku": "MAT-TELA-ALGODON", "totalQuantity": 3, "unit": "m", "sources": [{ "source": "BOM", "quantity": 3 }] }]
  },
  "details": [
    { "materialSku": "MAT-TELA-ALGODON", "required": 3, "available": 315, "sufficient": true }
  ]
}

Required permission: inventory:read

Executes the actual consumption of materials according to the product's BOM

productSkustringrequired

Product SKU.

locationIdstringrequired

Consumption location.

quantitynumber

Product quantity. Default 1.

selectedModifiersarray

Selected modifiers.

optionsobject

{ method?: 'fifo'|'lifo', referenceId?, referenceType? } — lot consumption method and reference (for example, the order that triggered the consumption).

{
  "productSku": "PROD-CAMISA-M",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 1,
  "options": { "method": "fifo", "referenceType": "order", "referenceId": "65f3a1b2c4d5e6f7a8b9c0d1" }
}
200
{
  "success": true,
  "consumed": [
    { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.5, "unit": "m", "lotsConsumed": [{ "lotNumber": "L-2026-001", "quantity": 1.5, "costPerUnit": 45.5 }] }
  ],
  "totalCost": 68.25,
  "currency": "MXN",
  "errors": []
}
400
{
  "code": "consumption-failed",
  "message": "Insufficient materials",
  "errors": [{ "materialSku": "MAT-TELA-ALGODON", "required": 5, "available": 2, "error": "Insufficient stock: need 5, have 2" }]
}

Required permission: inventory:update

Estimates the material cost of producing/selling a quantity of a product

productSkustringrequired

Product SKU.

locationIdstringrequired

Location (cost can vary by lot available at each location).

quantitynumber

Product quantity. Default 1.

selectedModifiersarray

Selected modifiers.

{
  "productSku": "PROD-CAMISA-M",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 1
}
200
{
  "totalCost": 68.25,
  "currency": "MXN",
  "breakdown": [
    { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.5, "unit": "m", "costPerUnit": 45.5, "totalCost": 68.25 }
  ]
}

Required permission: inventory:read

No validation for a non-existent product/BOM

All three consumption endpoints call MaterialConsumptionService.calculateConsumption, which throws Product {productSku} not found when the product doesn't exist; the handler only maps to 404 the messages that contain the substring "not found" — this one matches, so you will indeed see 404 not-found. If the product exists but has no billOfMaterials or modifiersConfig with consumption, the operation doesn't fail: it simply calculates over zero components (totalMaterials: 0).

Supplies

Supplies (supplies) are operational consumables — packaging, labels, office materials — with the same CRUD structure as materials, but without lot-based costing or expiration tracking. Just like materials, SuppliesService persists to a dedicated supplies collection with a flat shape — not in Product.supplyConfig.

Lists supplies with pagination and filters

pagenumber

Page, 0-based.

limitnumber

Items per page.

categorystring

Filter by supply category.

statusstring

Filter by status.

termstring

Text search.

200
{
  "data": [
    {
      "sku": "SUP-CAJA-CHICA",
      "title": { "value": "Caja de empaque chica" },
      "supplyCategory": "packaging",
      "consumptionUnit": "pza",
      "price": 0,
      "currency": "MXN",
      "stockAlerts": { "minStock": 50, "reorderPoint": 100, "reorderQuantity": 500, "alertEnabled": true },
      "status": "active"
    }
  ],
  "total": 1,
  "page": 0,
  "limit": 50,
  "totalPages": 1,
  "hasMore": false
}

Gets a supply by SKU

skustringrequired

Supply SKU.

404
{ "code": "not-found", "message": "Supply not found" }

Creates a supply

{
  "sku": "SUP-CAJA-CHICA",
  "title": { "value": "Caja de empaque chica" },
  "supplyCategory": "packaging",
  "consumptionUnit": "pza",
  "price": 8.5,
  "currency": "MXN",
  "stockAlerts": { "minStock": 50, "reorderPoint": 100, "reorderQuantity": 500, "alertEnabled": true }
}
201
{
  "sku": "SUP-CAJA-CHICA",
  "title": { "value": "Caja de empaque chica" },
  "supplyCategory": "packaging",
  "consumptionUnit": "pza",
  "price": 8.5,
  "currency": "MXN",
  "tags": [],
  "status": "active",
  "stockAlerts": { "minStock": 50, "reorderPoint": 100, "reorderQuantity": 500, "alertEnabled": true }
}
400
{ "code": "duplicate", "message": "Supply with SKU SUP-CAJA-CHICA already exists" }

Updates a supply (partial)

skustringrequired

Supply SKU.

{ "price": 9.0, "stockAlerts": { "minStock": 60, "reorderPoint": 120, "reorderQuantity": 500 } }
200
{
  "sku": "SUP-CAJA-CHICA",
  "title": { "value": "Caja de empaque chica" },
  "supplyCategory": "packaging",
  "consumptionUnit": "pza",
  "price": 9.0,
  "currency": "MXN",
  "status": "active",
  "stockAlerts": { "minStock": 60, "reorderPoint": 120, "reorderQuantity": 500, "alertEnabled": true }
}
404
{ "code": "not-found", "message": "Supply not found" }

Deletes a supply

skustringrequired

Supply SKU.

200
{ "success": true, "message": "Supply deleted" }
404
{ "code": "not-found", "message": "Supply not found" }

Required permission: analogous to materials — products:read (GET), products:create (POST), products:update (PUT), products:delete (DELETE).

Bill of Materials (BOM)

The BOM (Bill of Materials) defines which materials a product consumes — either when sold (on_sale), when produced (on_production), or only when triggered manually (manual). It lives in the billOfMaterials field of the product itself, not in a separate collection.

Gets a product's BOM

skustringrequired

Product SKU.

404
{ "code": "not-found", "message": "Product or BOM not found" }

Required permission: products:read

Assigns a BOM to a product

skustringrequired

Product SKU.

consumptionModestringrequired

'on_sale' | 'on_production' | 'manual'.

yieldobjectrequired

{ quantity, unit } — the recipe/assembly's yield.

componentsarrayrequired

At least one component: { materialSku, quantity, unit, required?(default true), sequence?, substitutes?[{materialSku,conversionFactor}], notes? }.

wastagePercentnumber

Expected wastage percentage.

preparationNotesobject

Preparation notes, translated field { value }.

prepTimeMinutesnumber

Estimated preparation time in minutes.

{
  "consumptionMode": "on_sale",
  "yield": { "quantity": 1, "unit": "pza" },
  "components": [
    { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.5, "unit": "m", "required": true },
    { "materialSku": "SUP-ETIQUETA", "quantity": 1, "unit": "pza", "required": true }
  ],
  "wastagePercent": 5
}
200
{
  "sku": "PROD-CAMISA-M",
  "billOfMaterials": {
    "consumptionMode": "on_sale",
    "yield": { "quantity": 1, "unit": "pza" },
    "components": [
      { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.5, "unit": "m", "required": true, "sequence": 0 },
      { "materialSku": "SUP-ETIQUETA", "quantity": 1, "unit": "pza", "required": true, "sequence": 1 }
    ],
    "wastagePercent": 5,
    "calculatedCost": { "value": 71.66, "currency": "MXN", "calculatedAt": "2026-07-21T12:00:00.000Z" }
  }
}

Required permission: products:update

200, not 201 — and no documented error ApiResponse

Despite being the operation that creates the BOM, assignBOM responds with 200 (not 201). If any materialSku in components doesn't exist, BOMService throws BOM validation failed: Material {sku} not found, but the handler has no branch that maps that message to 400 — it falls straight through to the generic catch and responds 500 internal-error, not a validation 400.

Updates a product's BOM (partial)

skustringrequired

Product SKU.

{ "wastagePercent": 8, "components": [{ "materialSku": "MAT-TELA-ALGODON", "quantity": 1.6, "unit": "m" }] }
200
{
  "sku": "PROD-CAMISA-M",
  "billOfMaterials": {
    "consumptionMode": "on_sale",
    "yield": { "quantity": 1, "unit": "pza" },
    "components": [
      { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.6, "unit": "m", "required": true, "sequence": 0 }
    ],
    "wastagePercent": 8,
    "calculatedCost": { "value": 78.62, "currency": "MXN", "calculatedAt": "2026-07-21T12:05:00.000Z" }
  }
}
404
{ "code": "not-found", "message": "Product not found" }

Required permission: products:update

Removes a product's BOM

skustringrequired

Product SKU.

200
{ "success": true, "message": "BOM removed" }
404
{ "code": "not-found", "message": "Product not found" }

Required permission: products:delete

Calculates the BOM's material consumption for a produced/sold quantity of the product

skustringrequired

Product SKU.

quantitynumberrequired

Quantity produced/sold (multiplied by each BOM component).

referenceIdstring

Reference ID (for example, the order).

referenceTypestring

'order' | 'production' | 'sample' | 'waste'.

allowSubstitutesboolean

Allow substitutes defined in the BOM.

notesstring

Free-form notes.

{ "quantity": 2, "referenceId": "65f3a1b2c4d5e6f7a8b9c0d1", "referenceType": "order" }
200
{
  "success": true,
  "quantityProduced": 2,
  "unit": "pza",
  "componentsConsumed": [
    { "materialSku": "MAT-TELA-ALGODON", "quantity": 3, "unit": "m", "costPerUnit": 45.5, "totalCost": 136.5, "substituted": false }
  ],
  "totalCost": 143.33,
  "currency": "MXN",
  "referenceId": "65f3a1b2c4d5e6f7a8b9c0d1",
  "referenceType": "order",
  "consumedAt": "2026-07-21T12:10:00.000Z"
}
500
{ "code": "internal-error", "message": "Failed to consume BOM" }

Required permission: products:update

Does not deduct real inventory or validate insufficient stock

BOMService.consumeComponents calculates the cost and quantities as if they were consumed (using each material's current cost), but does not actually deduct real inventory — the source code itself marks this explicitly: "In production, this would actually consume from inventory using MaterialsService.consumeMaterial()". That's why it always responds success:true, even without sufficient stock; there is currently no reachable 400 insufficient-stock on this endpoint. Additionally, if the product has no BOM assigned, the service throws Product {sku} has no BOM — that message doesn't contain the substring "not found" that the handler looks for to map to 404, so in that case you'll see 500 internal-error, not 404.

Modifiers

Modifiers implement food-delivery-style customization options (Uber Eats, Rappi, DiDi Food) — for example "no onion" or "extra cheese" — with price adjustment and, optionally, material consumption.

Gets a product's modifier configuration

skustringrequired

Product SKU.

200
{
  "enabled": true,
  "groups": [
    {
      "id": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10",
      "code": "group-0",
      "name": { "value": "Extras" },
      "selectionRule": { "minSelections": 0, "maxSelections": 3, "exactlyOne": false, "allowDuplicates": false },
      "modifiers": [
        {
          "id": "9d1e4a2c-5b7f-4e10-8a3c-1f2b3c4d5e6f",
          "code": "extra-queso",
          "name": { "value": "Extra queso" },
          "type": "addon",
          "priceAdjustment": { "type": "fixed", "value": 15, "currency": "MXN" },
          "available": true,
          "isDefault": false,
          "position": 0
        }
      ],
      "position": 0,
      "active": true
    }
  ]
}
404
{ "code": "not-found", "message": "Product or modifiers not found" }

Required permission: products:read

Configures a product's modifier groups (replaces the entire configuration)

skustringrequired

Product SKU.

enabledbooleanrequired

Whether modifiers are active for the product.

groupsarrayrequired

Modifier groups: { name, displayName?, selectionRule:{minSelections,maxSelections,exactlyOne,allowDuplicates,maxPerModifier?}, modifiers:[{name,type,sku?,priceAdjustment?,inventoryConsumption?:{materialSku,quantity,unit},isDefault?,isAvailable?,sortOrder?,channelBindings?}], sortOrder?, channelBindings? }.

{
  "enabled": true,
  "groups": [
    {
      "name": { "value": "Extras" },
      "selectionRule": { "minSelections": 0, "maxSelections": 3, "exactlyOne": false, "allowDuplicates": false },
      "modifiers": [
        {
          "name": { "value": "Extra queso" },
          "type": "addon",
          "sku": "extra-queso",
          "priceAdjustment": { "type": "fixed", "value": 15, "currency": "MXN" },
          "inventoryConsumption": { "materialSku": "SUP-QUESO-REBANADA", "quantity": 1, "unit": "pza" }
        }
      ]
    }
  ]
}
200
{
  "sku": "PROD-HAMBURGUESA",
  "modifiersConfig": {
    "enabled": true,
    "groups": [
      {
        "id": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10",
        "code": "group-0",
        "name": { "value": "Extras" },
        "selectionRule": { "minSelections": 0, "maxSelections": 3, "exactlyOne": false, "allowDuplicates": false },
        "modifiers": [
          {
            "id": "9d1e4a2c-5b7f-4e10-8a3c-1f2b3c4d5e6f",
            "code": "extra-queso",
            "name": { "value": "Extra queso" },
            "type": "addon",
            "priceAdjustment": { "type": "fixed", "value": 15, "currency": "MXN" },
            "inventoryConsumption": [{ "sku": "SUP-QUESO-REBANADA", "quantity": 1, "unit": "pza" }],
            "available": true,
            "isDefault": false,
            "position": 0
          }
        ],
        "position": 0,
        "active": true
      }
    ]
  }
}
404
{ "code": "not-found", "message": "Product not found" }

Required permission: products:update

Input materialSku is saved as sku

inventoryConsumption in the request uses the materialSku field (a single object). Once saved, ModifiersService.buildModifier converts it into an array with the field sku (not materialSku) — that's the shape you'll see in the response and in GET /products/{sku}/modifiers. Don't assume the field name is preserved between request and response.

Validates a modifier selection against the configured rules (for example, a group's min/max) and calculates the price adjustment and material consumption

skustringrequired

Product SKU.

selectionsarrayrequired

[{ groupId, modifierIds:string[], quantities?: Record<modifierId, number> }] — groupId/modifierId are the server-generated `id` (UUID), not the `code`.

{
  "selections": [
    { "groupId": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10", "modifierIds": ["9d1e4a2c-5b7f-4e10-8a3c-1f2b3c4d5e6f"] }
  ]
}
200
{
  "valid": true,
  "errors": [],
  "selectedModifiers": [
    {
      "groupId": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10",
      "groupName": "Extras",
      "modifierId": "9d1e4a2c-5b7f-4e10-8a3c-1f2b3c4d5e6f",
      "modifierName": "Extra queso",
      "quantity": 1,
      "priceAdjustment": 15,
      "inventoryConsumption": { "materialSku": "SUP-QUESO-REBANADA", "quantity": 1, "unit": "pza" }
    }
  ],
  "totalPriceAdjustment": 15,
  "materialsToConsume": [{ "materialSku": "SUP-QUESO-REBANADA", "quantity": 1, "unit": "pza" }]
}

Required permission: products:read

Updates a specific modifier group

skustringrequired

Product SKU.

groupIdstringrequired

The group's `id` (UUID) — NOT the `code`. The server looks up by `group.id === groupId`; using the `code` won't find the group (404).

nameobject

Translated field { value }.

displayNameobject

Saved as the group's `description`.

selectionRuleobject

{ minSelections, maxSelections, exactlyOne, allowDuplicates, maxPerModifier? }.

sortOrdernumber

Saved as the group's `position`.

channelBindingsarray

[{ channelId, externalGroupId?, isEnabled }].

{ "selectionRule": { "minSelections": 1, "maxSelections": 2, "exactlyOne": false, "allowDuplicates": false } }
200
{
  "sku": "PROD-HAMBURGUESA",
  "modifiersConfig": {
    "enabled": true,
    "groups": [
      {
        "id": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10",
        "code": "group-0",
        "name": { "value": "Extras" },
        "selectionRule": { "minSelections": 1, "maxSelections": 2, "exactlyOne": false, "allowDuplicates": false },
        "modifiers": [],
        "position": 0,
        "active": true
      }
    ]
  }
}
404
{ "code": "not-found", "message": "Product or group not found" }

Required permission: products:update

Disables the product's modifiers (enabled:false) — does NOT delete the configured groups

skustringrequired

Product SKU.

200
{ "success": true, "message": "Modifiers disabled" }
404
{ "code": "not-found", "message": "Product not found" }

Required permission: products:delete

Doesn't delete the configuration, only disables it

Despite its name and description, this endpoint invokes ModifiersService.toggleModifiers(tenantId, sku, false): it sets modifiersConfig.enabled = false but keeps groups[] intact. If you later re-enable (POST /products/{sku}/modifiers with enabled:true), the previous groups are still there. To actually delete groups, use DELETE /products/{sku}/modifiers/{groupId} for each one.

Deletes a specific modifier group

skustringrequired

Product SKU.

groupIdstringrequired

The group's `id` (UUID) — NOT the `code`.

200
{ "success": true, "message": "Modifier group removed" }
404
{ "code": "not-found", "message": "Product or group not found" }

Required permission: products:delete

A non-existent groupId doesn't return 404 — it's a silent no-op

ModifiersService.removeModifierGroup only validates that the product exists and has modifiersConfig; if the submitted groupId doesn't match any group, it runs filter() over the array (which removes nothing) and still responds 200 {success:true, message:"Modifier group removed"} — there is no way to distinguish "group deleted" from "groupId that never existed" from the response.

Data Model

Material (materialConfig, productType:'material')

FieldTypeRequiredNote
baseUnit{code, name, precision}Yesprecision max 6 decimals.
alternativeUnitsarrayNoAlternative units convertible to baseUnit.
costingobjectYesMethod: fifo, lifo, average, or last_purchase.
expiration{expires, shelfLifeDays, expirationPolicy}NoFor perishable materials.
suppliersarrayNoMaterial suppliers.
stockAlerts{minStock, reorderPoint, reorderQuantity, alertEmails, alertEnabled}Yes
lotTrackingEnabledbooleanNoEnables lot tracking (used by consumption/consume with options.method).

Supply (supplyConfig, productType:'supply')

FieldTypeRequiredNote
supplyCategoryenumNopackaging, shipping, office, cleaning, tools, labels, other. Default other.
consumptionUnitstringYesDefault pza.
stockAlertsobjectYesSame shape as materials.
typicalUsagePerOrdernumberNo
shippingAssociationNo
preferredSupplierIdstringNo

Bill of Materials (billOfMaterials, any product)

FieldTypeRequiredNote
consumptionModeenumNoon_sale, on_production, or manual.
yield{quantity, unit}NoThe recipe/assembly's yield.
componentsarrayYesAt least one component: materialSku, quantity, unit required; substitutes[] optional.
wastagePercentnumberNoMaximum 100.

Modifiers (modifiersConfig)

FieldTypeRequiredNote
enabledbooleanNoDefault false.
groupsarrayNoEach group: code, name required, plus selectionRule and modifiers[].
modifiers[].priceAdjustment{type, value, currency}Yes (per modifier)type: fixed, percentage, or replace.
modifiers[].channelBindings[].channelTypeenumNouber-eats, rappi, didi-food, mercado-libre, shopify, other.
conditions{variantSkus, availableHours, availableDays}NoavailableHours in HH:MM format.

Bundle (bundleConfig, only bundle productType/type) — reminder

bundleConfig has no endpoints of its own. It is managed within PUT /products/{id} (see Update a product). Structure: inventoryMode (calculated or reserved, default calculated) and components[] with at least one element — productSku required, variantMode (fixed/selectable/any, default fixed), quantity (minimum 1, default 1), required (default true).

Example — Check and consume materials on a sale

curl -X POST https://api.fenicia.io/products/materials/consumption/consume \
  -H "Authorization: Bearer fkapi_tu_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "productSku": "PROD-CAMISA-M",
    "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
    "quantity": 1,
    "options": { "method": "fifo", "referenceType": "order", "referenceId": "65f3a1b2c4d5e6f7a8b9c0d1" }
  }'

Errors

CodeStatusDescription
validation-error400Invalid or missing field in the body.
duplicate-sku409A material/supply/product with that SKU already exists in your tenant.
not-found404The requested material, supply, product, or inventory record doesn't exist.
insufficient-stock400Not enough stock of the material to complete the BOM's consumption.
consumption-failed400Material consumption failed; check errors[] for the per-material detail.
transfer-failed400The material inventory transfer failed (for example, insufficient stock at source).
auth:invalid_token401The API key is invalid or has been revoked.
auth:permission_denied403The API key doesn't have the required permission for this operation.

See the full error catalog for the rest of the possible codes.

Next Steps