{
"id": "DeterministicExactMatchQuoteGuard01",
"name": "Deterministic Exact-Match Quote Guard for AI and OCR Inputs",
"description": "Credential-free synthetic template that validates untrusted extracted line items, exact-matches references against a fixed catalog, ignores claimed prices, and returns HTTP 422 with NEEDS_REVIEW for caller-side human handling.",
"active": false,
"isArchived": false,
"nodes": [
{
"parameters": {
"content": "## Setup and safety boundary\n- The catalog, prices, labor rate, travel fee, and VAT are synthetic fixtures. Replace them with an authenticated source and approved business rules.\n- The webhook has no authentication in this template. Add Header Auth or JWT Auth before publishing it.\n- Treat `extracted_items` as untrusted AI or OCR output. Prices supplied by that input are ignored.\n- Unknown references return `NEEDS_REVIEW` with `totals: null`. Keep a human approval step before sending a quote.\n- Test concurrency, rounding, taxes, discounts, audit storage, and retries against your own acceptance cases.",
"height": 400,
"width": 500
},
"id": "db7d6e18-367c-4be7-9b07-6d1a7a4938b4",
"name": "Setup and safety boundary",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-560,
-40
]
},
{
"parameters": {
"jsCode": "return $input.all().map((item) => ({ json: {\n ok: false,\n status: 'REJECTED',\n request_id: item.json.request_id,\n invalid_fields: item.json.invalid_fields\n} }));"
},
"id": "8bb4a0de-555c-4e0a-ba4a-4ed74c4e82b5",
"name": "Build Rejected Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
780,
280
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "deterministic-exact-match-quote",
"authentication": "none",
"responseMode": "responseNode",
"options": {}
},
"id": "8393076b-bbcf-4aad-88c3-1be227eb03a1",
"name": "Receive Synthetic Request",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
0,
160
],
"webhookId": "2700215e-9c14-4aca-91f5-df0434fd6865"
},
{
"parameters": {
"jsCode": "const input = $json.body ?? $json;\nconst requestId = typeof input.request_id === 'string' ? input.request_id : '';\nconst sourceDescription = typeof input.source_description === 'string' ? input.source_description : '';\nconst laborHours = input.labor_hours;\nconst travelRequired = input.travel_required;\nconst extractedItems = Array.isArray(input.extracted_items) ? input.extracted_items : [];\nconst errors = [];\nif (!/^quote-[a-z0-9-]{3,40}$/i.test(requestId)) errors.push('request_id');\nif (!sourceDescription || sourceDescription.length > 1000) errors.push('source_description');\nconst laborHundredths = typeof laborHours === 'number' ? laborHours * 100 : Number.NaN;\nif (typeof laborHours !== 'number' || !Number.isFinite(laborHours) || laborHours < 0 || laborHours > 24 || Math.abs(Math.round(laborHundredths) - laborHundredths) > 1e-9) errors.push('labor_hours');\nif (typeof travelRequired !== 'boolean') errors.push('travel_required');\nif (extractedItems.length < 1 || extractedItems.length > 10) errors.push('extracted_items');\nconst normalizedItems = extractedItems.map((item, index) => {\n const reference = typeof item?.reference === 'string' ? item.reference : '';\n const quantity = item?.quantity;\n if (!/^SYN-[A-Z]+-[0-9]{3}$/.test(reference)) errors.push(`extracted_items[${index}].reference`);\n if (!Number.isInteger(quantity) || quantity < 1 || quantity > 20) errors.push(`extracted_items[${index}].quantity`);\n if (item?.claimed_unit_price_chf !== undefined && (typeof item.claimed_unit_price_chf !== 'number' || !Number.isFinite(item.claimed_unit_price_chf))) errors.push(`extracted_items[${index}].claimed_unit_price_chf`);\n return { reference, quantity, claimed_unit_price_chf: item?.claimed_unit_price_chf ?? null };\n});\nreturn [{ json: {\n ok: errors.length === 0,\n status: errors.length === 0 ? 'VALIDATED' : 'REJECTED',\n request_id: requestId || null,\n invalid_fields: [...new Set(errors)],\n source_description: sourceDescription,\n labor_hours: laborHours,\n travel_required: travelRequired,\n extracted_items: normalizedItems,\n extraction_boundary: 'untrusted_synthetic_fixture'\n} }];"
},
"id": "44e9da82-fae1-4732-b21b-24217c6c46f3",
"name": "Validate Untrusted Extraction",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
260,
160
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.ok }}",
"operation": "equal",
"value2": true
}
]
},
"combineOperation": "all"
},
"id": "ab1a8413-08a8-4f0e-94d8-80018ed67d04",
"name": "Is Input Valid",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
520,
160
]
},
{
"parameters": {
"jsCode": "const catalog = Object.freeze({\n 'SYN-VALVE-100': Object.freeze({ name: 'Synthetic isolation valve 100', unit_price_cents: 12000 }),\n 'SYN-PIPE-200': Object.freeze({ name: 'Synthetic pipe section 200', unit_price_cents: 8550 }),\n 'SYN-SEAL-300': Object.freeze({ name: 'Synthetic seal kit 300', unit_price_cents: 3240 })\n});\nconst centsToChf = (value) => Number((value / 100).toFixed(2));\nconst matchedItems = [];\nconst missingReferences = [];\nlet ignoredClaimedPrices = false;\nfor (const item of $json.extracted_items) {\n const catalogItem = catalog[item.reference];\n if (!catalogItem) {\n missingReferences.push(item.reference);\n continue;\n }\n if (item.claimed_unit_price_chf !== null && Math.round(item.claimed_unit_price_chf * 100) !== catalogItem.unit_price_cents) ignoredClaimedPrices = true;\n const lineTotalCents = catalogItem.unit_price_cents * item.quantity;\n matchedItems.push({\n reference: item.reference,\n name: catalogItem.name,\n quantity: item.quantity,\n unit_price_chf: centsToChf(catalogItem.unit_price_cents),\n line_total_chf: centsToChf(lineTotalCents),\n line_total_cents: lineTotalCents\n });\n}\nif (missingReferences.length > 0) {\n return [{ json: {\n ok: false,\n status: 'NEEDS_REVIEW',\n request_id: $json.request_id,\n exact_match: false,\n matched_items: matchedItems.map(({ line_total_cents, ...item }) => item),\n missing_references: missingReferences,\n ignored_claimed_prices: ignoredClaimedPrices,\n totals: null,\n reason: 'exact_catalog_match_required'\n } }];\n}\nconst materialsCents = matchedItems.reduce((sum, item) => sum + item.line_total_cents, 0);\nconst marginCents = Math.round(materialsCents * 0.15);\nconst laborCents = Math.round($json.labor_hours * 9500);\nconst travelCents = $json.travel_required ? 4500 : 0;\nconst netCents = materialsCents + marginCents + laborCents + travelCents;\nconst vatCents = Math.round(netCents * 0.081);\nconst totalCents = netCents + vatCents;\nreturn [{ json: {\n ok: true,\n status: 'QUOTABLE',\n request_id: $json.request_id,\n exact_match: true,\n matched_items: matchedItems.map(({ line_total_cents, ...item }) => item),\n missing_references: [],\n ignored_claimed_prices: ignoredClaimedPrices,\n rates: { material_margin_percent: 15, labor_hour_chf: 95, travel_flat_chf: 45, vat_percent: 8.1 },\n totals: {\n materials_chf: centsToChf(materialsCents),\n material_margin_chf: centsToChf(marginCents),\n labor_chf: centsToChf(laborCents),\n travel_chf: centsToChf(travelCents),\n net_chf: centsToChf(netCents),\n vat_chf: centsToChf(vatCents),\n total_chf: centsToChf(totalCents)\n }\n} }];"
},
"id": "f8edc20b-47ee-4a7d-b6df-a8724442150d",
"name": "Exact Match and Calculate",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
780,
80
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.ok }}",
"operation": "equal",
"value2": true
}
]
},
"combineOperation": "all"
},
"id": "3425d12f-80c4-4c64-9dde-1e0bb89b8f0b",
"name": "Is Quote Ready",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
1040,
80
]
},
{
"parameters": {
"respondWith": "firstIncomingItem",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Cache-Control",
"value": "no-store"
}
]
}
}
},
"id": "9f999379-8ddd-4675-a9ec-d09e033dc87c",
"name": "Respond Quotable",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
1300,
0
]
},
{
"parameters": {
"respondWith": "firstIncomingItem",
"options": {
"responseCode": 422,
"responseHeaders": {
"entries": [
{
"name": "Cache-Control",
"value": "no-store"
}
]
}
}
},
"id": "be0f7ac1-6490-4848-9fc9-9b4dfaf98a4f",
"name": "Respond Needs Review",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
1300,
160
]
},
{
"parameters": {
"respondWith": "firstIncomingItem",
"options": {
"responseCode": 400,
"responseHeaders": {
"entries": [
{
"name": "Cache-Control",
"value": "no-store"
}
]
}
}
},
"id": "93bdcb43-aacb-4609-9045-ebc87ce031a3",
"name": "Respond Rejected",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
1040,
280
]
}
],
"connections": {
"Receive Synthetic Request": {
"main": [
[
{
"node": "Validate Untrusted Extraction",
"type": "main",
"index": 0
}
]
]
},
"Validate Untrusted Extraction": {
"main": [
[
{
"node": "Is Input Valid",
"type": "main",
"index": 0
}
]
]
},
"Is Input Valid": {
"main": [
[
{
"node": "Exact Match and Calculate",
"type": "main",
"index": 0
}
],
[
{
"node": "Build Rejected Response",
"type": "main",
"index": 0
}
]
]
},
"Exact Match and Calculate": {
"main": [
[
{
"node": "Is Quote Ready",
"type": "main",
"index": 0
}
]
]
},
"Is Quote Ready": {
"main": [
[
{
"node": "Respond Quotable",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond Needs Review",
"type": "main",
"index": 0
}
]
]
},
"Build Rejected Response": {
"main": [
[
{
"node": "Respond Rejected",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"meta": {
"templateCredsSetupCompleted": true
},
"nodeGroups": [],
"pinData": null,
"versionId": "f84b15d3-ac60-4dcf-b3f7-6b6501ccf240",
"activeVersionId": "f84b15d3-ac60-4dcf-b3f7-6b6501ccf240",
"sourceWorkflowId": null,
"tags": [],
"versionMetadata": {
"name": null,
"description": null
}
}