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 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.
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? }].
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).
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) 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.
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? }.
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 500internal-error, not a validation 400.
{ "code": "internal-error", "message": "Failed to consume BOM" }
Required permission:products:update
Does not deduct real inventory or validate insufficient stock
BOMService.consumeComponentscalculates 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 500internal-error, not 404.
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.
{ "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`.
{ "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 keepsgroups[] 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.
bundleConfighas 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).