{
  "name": "FLUF + Airtable \u2014 sales, consignment and packing",
  "nodes": [
    {
      "id": "bf1f15d1-d180-5d3b-b620-1e220f678343",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -660,
        -440
      ],
      "parameters": {
        "content": "## Track FLUF marketplace sales and packing tasks in Airtable\n\n### How it works\nWhen any of your marketplaces reports a sale through FLUF, this workflow adds one Sales row per order line (with revenue, cost and consignor from your Inventory row) and a Packing task. Rows are upserted by key, so a repeated delivery never creates duplicates. This workflow does not publish products; `allowPublish` is not used.\n\n### Setup\n1. Copy the Airtable template (link below) or use your own base with the same field names.\n2. Open **Configure workspace** and paste your base ID and the Inventory, Sales and Tasks table IDs.\n3. Select your Airtable personal access token credential on every Airtable request node.\n4. On FLUF HTTP request nodes use Header Auth: `Authorization` = `Bearer YOUR_FLUF_PAT`.\n5. Select your FLUF Connect API credential on each FLUF trigger.\n6. In FLUF, choose n8n as the automation owner for this base (one owner per base).\n7. Test with zero-stock drafts first, then activate.\n\n### Requirements\nYour own n8n workspace, an Airtable base and a paid FLUF plan with API write access (not included in the trial).\n\nTemplate base: https://airtable.com/appGvPOBL2b7Lfxwg/shryPFF1iDosEzitJ\nGuide: https://fluf.io/support/connect-airtable/",
        "height": 1000,
        "width": 560,
        "color": 4
      }
    },
    {
      "id": "cae1933a-2ea4-5ffe-b407-170cb04e6189",
      "name": "Note 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -260
      ],
      "parameters": {
        "content": "## 1. Sale received\nThe FLUF Connect trigger fires on every new sale. **Configure workspace** holds your base and table IDs.",
        "height": 420,
        "width": 520,
        "color": 7
      }
    },
    {
      "id": "6352cfa1-9fe2-579e-bd80-64aece532ba0",
      "name": "Note 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        540,
        -260
      ],
      "parameters": {
        "content": "## 2. Look up the item\n**Prepare sale lines** splits the order into lines. Each line looks up its Inventory row by FLUF ID to read Cost, Currency, Consignor and Commission percent.\nAirtable and FLUF requests make up to three attempts in total, waiting 35 seconds after a rate limit (429) or server error. Other errors stop the run so you can see them in the execution.",
        "height": 620,
        "width": 1240,
        "color": 7
      }
    },
    {
      "id": "d3a443fd-ad09-56dd-a13d-597c6fb4bcf9",
      "name": "Note 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1880,
        -260
      ],
      "parameters": {
        "content": "## 3. Write sale and packing task\nBuilds the Sales row (cost only when the currency matches the sale) and a Packing task, then upserts both by Sale key and Task key. Duplicate FLUF IDs in Inventory stop the run.",
        "height": 620,
        "width": 1240,
        "color": 7
      }
    },
    {
      "id": "719b8a9c-0f8c-551c-ada6-2ad0867cfa43",
      "name": "New sale",
      "type": "n8n-nodes-fluf-connect.flufConnectTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {
        "event": "new_sale"
      },
      "webhookId": "eb0bf344-babc-52ce-a466-56012168aa2a"
    },
    {
      "id": "8a4d53f4-dff9-5df9-8ac3-212e52f75dbe",
      "name": "Configure workspace",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        300,
        0
      ],
      "parameters": {
        "jsCode": "// Choose a base and its four FLUF tables. Use standard field names from the guide.\nconst config = { baseId: 'REPLACE_BASE_ID', inventory: 'REPLACE_INVENTORY_TABLE_ID', sales: 'REPLACE_SALES_TABLE_ID', tasks: 'REPLACE_TASKS_TABLE_ID', allowPublish: false };\nif (!/^app[A-Za-z0-9]+$/.test(config.baseId)) throw new Error('Set your Airtable base ID in Configure workspace.');\nfor (const key of ['inventory','sales','tasks']) if (!/^tbl[A-Za-z0-9]+$/.test(config[key])) throw new Error('Set the '+key+' table ID.');\nreturn $input.all().map((item,i)=>({json:{...item.json,config},pairedItem:{item:i}}));"
      }
    },
    {
      "id": "95fd987f-4adf-5b29-b4fa-c5d0540a229b",
      "name": "Prepare sale lines",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        600,
        0
      ],
      "parameters": {
        "jsCode": "return $input.all().flatMap((item,i)=>(item.json.items||[]).map((line,index)=>({json:{event:item.json,line,index,config:item.json.config},pairedItem:{item:i}})));"
      }
    },
    {
      "id": "27db9813-8fb9-50c5-8d25-404178494266",
      "name": "Request Read item costs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1140,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.airtable.com/v0/'+$json.config.baseId+'/'+$json.config.inventory+'?maxRecords=2&filterByFormula='+encodeURIComponent('{FLUF ID}=\"'+Number($json.line.fluf_id||0)+'\"') }}",
        "authentication": "predefinedCredentialType",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 350
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        },
        "nodeCredentialType": "airtableTokenApi"
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "df94ce56-7fe2-5f48-877c-6d6148e6c5e6",
      "name": "Prepare sale and packing writes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1940,
        0
      ],
      "parameters": {
        "jsCode": "return $input.all().flatMap((item,i)=>{\n const ctx=$('Prepare sale lines').itemMatching(i).json, {event:e,line:l,index,config:c}=ctx;\n if ((item.json.records||[]).length>1) throw new Error('Duplicate FLUF IDs in inventory. Merge the duplicate rows before retrying.');\n const p=item.json.records?.[0]?.fields||{}, key=[e.channel,e.connection_id||'',e.order_id,index].join(':');\n const quantity=Number(l.quantity??1), price=l.price==null?null:Number(l.price);\n const fields={'Sale key':key,'Order ID':String(e.order_id),'FLUF ID':l.fluf_id?String(l.fluf_id):'','SKU':l.sku||'','Title':l.title||'','Marketplace':e.channel||'','Store':String(e.connection_id||''),'Sold at':e.sold_at||'','Currency':e.currency||'','Quantity':quantity,'Unit price':price,'Revenue':price===null?null:Math.round(price*quantity*100)/100,'Consignor':p.Consignor||''};\n if(p.Cost!=null && p.Currency===e.currency) fields.Cost=Number(p.Cost)*quantity;\n if(p['Commission percent']!=null) fields['Commission percent']=Number(p['Commission percent']);\n const task={'Task key':'pack:'+key,'FLUF ID':fields['FLUF ID'],'SKU':fields.SKU,'Type':'Packing','Marketplace':fields.Marketplace,'Details':'Pack order '+e.order_id,'Created at':fields['Sold at']};\n return [{table:c.sales,key:'Sale key',fields},{table:c.tasks,key:'Task key',fields:task}].map(w=>({json:{...w,config:c},pairedItem:{item:i}}));\n});"
      }
    },
    {
      "id": "913d2e3f-1d4d-5cd5-b213-d873b929cfe9",
      "name": "Request Write sales and packing tasks",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2480,
        0
      ],
      "parameters": {
        "method": "PATCH",
        "url": "={{ 'https://api.airtable.com/v0/'+$json.config.baseId+'/'+$json.table }}",
        "authentication": "predefinedCredentialType",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 350
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        },
        "nodeCredentialType": "airtableTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ {performUpsert:{fieldsToMergeOn:[$json.key]},records:[{fields:$json.fields}]} }}"
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "adbef4b7-ca0c-5172-9932-a13abc461815",
      "name": "Attempt Read item costs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        0
      ],
      "parameters": {
        "jsCode": "return $input.all().map((item,i)=>({json:{...item.json,__airtableAttempt:Number(item.json.__airtableAttempt||0)+1},pairedItem:{item:i}}));"
      }
    },
    {
      "id": "e185cc74-0bf5-5007-bf26-2e21bd2992bb",
      "name": "Read item costs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1380,
        0
      ],
      "parameters": {
        "jsCode": "return $input.all().map((item,i)=>{\n const before=$(\"Attempt Read item costs\").itemMatching(i).json, response=item.json;\n const status=Number(response.statusCode||0);\n if(status>=200 && status<300) return {json:{...response.body,__airtableRetry:false},pairedItem:{item:i}};\n if((status===0||status===429||status>=500) && before.__airtableAttempt<3) return {json:{...before,__airtableRetry:true},pairedItem:{item:i}};\n const error=response.body?.message||response.body?.error?.message||'The request failed. Check the connection, field mapping and service allowance.';\n if(!false) throw new Error(String(error));\n return {json:{error:String(error),__airtableRetry:false},pairedItem:{item:i}};\n});"
      }
    },
    {
      "id": "0cc5d435-12ec-5cd8-9bca-71dc971c3dfe",
      "name": "Retry Read item costs",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1620,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "retry",
              "leftValue": "={{ $json.__airtableRetry }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "fd66f0af-ff4c-55f4-a977-56396a608292",
      "name": "Wait for Read item costs",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1620,
        200
      ],
      "parameters": {
        "amount": 35,
        "unit": "seconds"
      },
      "webhookId": "dc87dfce-cdc2-5208-96e2-6638f475a258"
    },
    {
      "id": "38d897dd-7579-5a2e-a597-fe00dbd41b02",
      "name": "Attempt Write sales and packing tasks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2240,
        0
      ],
      "parameters": {
        "jsCode": "return $input.all().map((item,i)=>({json:{...item.json,__airtableAttempt:Number(item.json.__airtableAttempt||0)+1},pairedItem:{item:i}}));"
      }
    },
    {
      "id": "1b2ae6b7-1622-5589-9377-9baabd16f0e4",
      "name": "Write sales and packing tasks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2720,
        0
      ],
      "parameters": {
        "jsCode": "return $input.all().map((item,i)=>{\n const before=$(\"Attempt Write sales and packing tasks\").itemMatching(i).json, response=item.json;\n const status=Number(response.statusCode||0);\n if(status>=200 && status<300) return {json:{...response.body,__airtableRetry:false},pairedItem:{item:i}};\n if((status===0||status===429||status>=500) && before.__airtableAttempt<3) return {json:{...before,__airtableRetry:true},pairedItem:{item:i}};\n const error=response.body?.message||response.body?.error?.message||'The request failed. Check the connection, field mapping and service allowance.';\n if(!false) throw new Error(String(error));\n return {json:{error:String(error),__airtableRetry:false},pairedItem:{item:i}};\n});"
      }
    },
    {
      "id": "ec215a2f-8596-58fe-ac74-fee3a5e6aac3",
      "name": "Retry Write sales and packing tasks",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2960,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "retry",
              "leftValue": "={{ $json.__airtableRetry }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "d7a79e8e-fb82-508f-968b-665d21d7b2aa",
      "name": "Wait for Write sales and packing tasks",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        2960,
        200
      ],
      "parameters": {
        "amount": 35,
        "unit": "seconds"
      },
      "webhookId": "afb5a3f8-3bb1-5bc3-bc63-be021a50b665"
    }
  ],
  "connections": {
    "New sale": {
      "main": [
        [
          {
            "node": "Configure workspace",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configure workspace": {
      "main": [
        [
          {
            "node": "Prepare sale lines",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare sale lines": {
      "main": [
        [
          {
            "node": "Attempt Read item costs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare sale and packing writes": {
      "main": [
        [
          {
            "node": "Attempt Write sales and packing tasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attempt Read item costs": {
      "main": [
        [
          {
            "node": "Request Read item costs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Request Read item costs": {
      "main": [
        [
          {
            "node": "Read item costs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read item costs": {
      "main": [
        [
          {
            "node": "Retry Read item costs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for Read item costs": {
      "main": [
        [
          {
            "node": "Attempt Read item costs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attempt Write sales and packing tasks": {
      "main": [
        [
          {
            "node": "Request Write sales and packing tasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Request Write sales and packing tasks": {
      "main": [
        [
          {
            "node": "Write sales and packing tasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write sales and packing tasks": {
      "main": [
        [
          {
            "node": "Retry Write sales and packing tasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for Write sales and packing tasks": {
      "main": [
        [
          {
            "node": "Attempt Write sales and packing tasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Retry Read item costs": {
      "main": [
        [
          {
            "node": "Wait for Read item costs",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare sale and packing writes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Retry Write sales and packing tasks": {
      "main": [
        [
          {
            "node": "Wait for Write sales and packing tasks",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "executionTimeout": 300
  },
  "tags": []
}
