diff --git a/.cursor/mcp.json b/.cursor/mcp.json
new file mode 100644
index 0000000..2c00b26
--- /dev/null
+++ b/.cursor/mcp.json
@@ -0,0 +1,15 @@
+{
+ "_comment": "JEISA PocketBase MCP — for Cursor / Claude Desktop / Claude Code. Lets an AI agent read & write PocketBase collections: customer_pos, invoices, stock_items, customers, etc.",
+ "_setup": "1) Get a PB superuser (admin) email + password. The default is the one in .env (david@wfsales.co.za). 2) Fill in pocketbase_url, pocketbase_admin_email, pocketbase_admin_password. 3) Restart Cursor / Claude Desktop.",
+ "mcpServers": {
+ "jeisa-pocketbase": {
+ "command": "npx",
+ "args": ["-y", "pocketbase-mcp-bridge"],
+ "env": {
+ "POCKETBASE_URL": "https://pb.jeistk.apphub.co.za",
+ "POCKETBASE_ADMIN_EMAIL": "REPLACE_ME_admin_email",
+ "POCKETBASE_ADMIN_PASSWORD": "REPLACE_ME_admin_password"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1c9ec62
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,49 @@
+# =============================================================
+# JEISA Automation: Customer PO → Invoice → Stock decrement
+# =============================================================
+#
+# Copy this file to `.env` (root of the JEISA app) and fill in
+# the values. These are read by:
+# • The PocketBase MCP server (so an AI agent can read/write PB)
+# • n8N credentials (when you configure the IMAP / QB / SMTP nodes)
+#
+# DO NOT commit `.env` to git. (Already in .gitignore.)
+# =============================================================
+
+# ---------- PocketBase ----------
+VITE_PB_URL=https://pb.jeistk.apphub.co.za
+POCKETBASE_URL=https://pb.jeistk.apphub.co.za
+
+# Superuser (admin) credentials for the MCP and n8N HTTP Request nodes.
+# You can find these in /Volumes/EXT/DEVELOPMENT-FORGEJO/JEISA-APP/setup-users.js
+# or in your PB dashboard under Settings → Admins.
+POCKETBASE_ADMIN_EMAIL=david@wfsales.co.za
+POCKETBASE_ADMIN_PASSWORD=REPLACE_ME_admin_password
+
+# ---------- IMAP (incoming customer PO inbox) ----------
+# This is the mailbox customers send their POs to.
+# IMAP_HOST is host:port (e.g. imap.gmail.com:993)
+IMAP_HOST=imap.gmail.com
+IMAP_PORT=993
+IMAP_USER=orders@jeisa.co.za
+IMAP_PASSWORD=REPLACE_ME_app_password
+IMAP_TLS=true
+
+# ---------- QuickBooks Online ----------
+# OAuth2 app credentials (https://developer.intuit.com/app/developer/dashboard)
+# After you create the app, generate sandbox + production keys.
+QB_CLIENT_ID=REPLACE_ME_client_id
+QB_CLIENT_SECRET=REPLACE_ME_client_secret
+QB_REALM_ID=REPLACE_ME_realm_id
+QB_ENVIRONMENT=sandbox
+
+# ---------- SMTP (fallback for sending invoice if QB can't) ----------
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_USER=invoices@jeisa.co.za
+SMTP_PASSWORD=REPLACE_ME_smtp_password
+SMTP_FROM_NAME=JEISA Invoices
+
+# ---------- Optional: OpenAI (for AI-powered PDF parsing) ----------
+# If your POs aren't machine-readable, use AI to extract line items.
+OPENAI_API_KEY=REPLACE_ME_openai_key
\ No newline at end of file
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..3d2194e
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,15 @@
+{
+ "_comment": "JEISA PocketBase MCP — for Claude Desktop. Same as .cursor/mcp.json.",
+ "_setup": "Copy to ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, or %APPDATA%/Claude/claude_desktop_config.json on Windows. Then restart Claude Desktop.",
+ "mcpServers": {
+ "jeisa-pocketbase": {
+ "command": "npx",
+ "args": ["-y", "pocketbase-mcp-bridge"],
+ "env": {
+ "POCKETBASE_URL": "https://pb.jeistk.apphub.co.za",
+ "POCKETBASE_ADMIN_EMAIL": "REPLACE_ME_admin_email",
+ "POCKETBASE_ADMIN_PASSWORD": "REPLACE_ME_admin_password"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/n8n/01-customer-po-to-parsed.json b/n8n/01-customer-po-to-parsed.json
new file mode 100644
index 0000000..f4ca21c
--- /dev/null
+++ b/n8n/01-customer-po-to-parsed.json
@@ -0,0 +1,535 @@
+{
+ "name": "JEISA: Customer PO → Upload → Parse",
+ "description": "Monitors the JEI orders inbox, uploads every PDF PO to PocketBase as a `customer_pos` record, then parses the PDF for line items (sku, qty, price). Two workflows in series: this one stops at status='parsed' and the next workflow ('Create Invoice from PO') picks up from there.",
+ "active": false,
+ "settings": {
+ "executionOrder": "v1",
+ "saveExecutionProgress": true,
+ "saveManualExecutions": true,
+ "saveDataSuccessExecution": "all",
+ "saveDataErrorExecution": "all",
+ "timezone": "Africa/Johannesburg"
+ },
+ "nodes": [
+ {
+ "parameters": {
+ "mailbox": "INBOX",
+ "postProcessAction": "read",
+ "options": {
+ "folderChanged": false
+ }
+ },
+ "id": "11111111-1111-1111-1111-111111111101",
+ "name": "IMAP: New Email",
+ "type": "n8n-nodes-base.emailReadImap",
+ "typeVersion": 2,
+ "position": [240, 300],
+ "webhookId": "jeisa-imap-trigger",
+ "credentials": {
+ "imap": {
+ "id": "REPLACE_ME_imap_credential",
+ "name": "JEISA IMAP (orders@jeisa)"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "cond-1",
+ "leftValue": "={{ $json.hasAttachments }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ },
+ {
+ "id": "cond-2",
+ "leftValue": "={{ $json.subject }}",
+ "rightValue": "(?i)po|purchase order|order",
+ "operator": {
+ "type": "string",
+ "operation": "regex"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-111111111102",
+ "name": "Filter: Has PDF + looks like PO",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2,
+ "position": [460, 300]
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "https://pb.jeistk.apphub.co.za/api/collections/email_inbox/records",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"imap_uid\": \"{{ $('IMAP: New Email').item.json.messageId }}\",\n \"from\": \"{{ $('IMAP: New Email').item.json.from }}\",\n \"subject\": \"{{ $('IMAP: New Email').item.json.subject }}\",\n \"to\": \"{{ $('IMAP: New Email').item.json.to }}\",\n \"body_plain\": \"{{ $('IMAP: New Email').item.json.textPlain }}\",\n \"received_at\": \"{{ $('IMAP: New Email').item.json.date }}\",\n \"has_attachments\": {{ $('IMAP: New Email').item.json.hasAttachments }},\n \"attachment_names\": \"{{ $('IMAP: New Email').item.json.attachments.map(a => a.filename).join(', ') }}\",\n \"disposition\": \"processed\"\n}",
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-111111111103",
+ "name": "PB: Log email_inbox",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [680, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "https://pb.jeistk.apphub.co.za/api/collections/email_inbox/records",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"imap_uid\": \"{{ $json.messageId }}\",\n \"from\": \"{{ $json.from }}\",\n \"subject\": \"{{ $json.subject }}\",\n \"to\": \"{{ $json.to }}\",\n \"received_at\": \"{{ $json.date }}\",\n \"has_attachments\": {{ $json.hasAttachments }},\n \"disposition\": \"ignored\"\n}",
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-111111111104",
+ "name": "PB: Log ignored email",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [680, 400],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "GET",
+ "url": "=https://pb.jeistk.apphub.co.za/api/collections/customers/records?filter=email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22%20%7C%7C%20alternative_email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22%20%7C%7C%20buyer_email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22&perPage=1",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-111111111105",
+ "name": "PB: Find customer by email",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [900, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"customer\": \"{{ $json.items[0].id }}\",\n \"source_email_from\": \"{{ $('IMAP: New Email').item.json.from.value[0].address }}\",\n \"source_email_subject\": \"{{ $('IMAP: New Email').item.json.subject }}\",\n \"source_email_received_at\": \"{{ $('IMAP: New Email').item.json.date }}\",\n \"source_email_uid\": \"{{ $('IMAP: New Email').item.json.messageId }}\",\n \"status\": \"new\"\n}",
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-111111111106",
+ "name": "PB: Create customer_pos record",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [1120, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "GET",
+ "url": "https://pb.jeistk.apphub.co.za/api/collections/customers/records?filter=email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22%20%7C%7C%20alternative_email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22%20%7C%7C%20buyer_email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22&perPage=1",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-111111111107",
+ "name": "PB: Find customer by email (fallback)",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [900, 400],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"source_email_from\": \"{{ $('IMAP: New Email').item.json.from.value[0].address }}\",\n \"source_email_subject\": \"{{ $('IMAP: New Email').item.json.subject }}\",\n \"source_email_received_at\": \"{{ $('IMAP: New Email').item.json.date }}\",\n \"source_email_uid\": \"{{ $('IMAP: New Email').item.json.messageId }}\",\n \"status\": \"new\",\n \"notes\": \"Auto-created: no matching customer in PB. Needs manual review.\"\n}",
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-111111111108",
+ "name": "PB: Create customer_pos (no customer)",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [1120, 400],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "cond-3",
+ "leftValue": "={{ $json.items.length }}",
+ "rightValue": 0,
+ "operator": {
+ "type": "number",
+ "operation": "gt"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-111111111109",
+ "name": "If: customer found",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2,
+ "position": [1340, 200]
+ },
+ {
+ "parameters": {
+ "method": "PATCH",
+ "url": "=https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records/{{ $json.id }}",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"customer\": \"{{ $('PB: Find customer by email').item.json.items[0].id }}\",\n \"status\": \"new\"\n}",
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-11111111110a",
+ "name": "PB: Link customer to PO",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [1560, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "=https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records/{{ $json.id }}/po_pdf",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendBody": true,
+ "specifyBody": "multipart-form-data",
+ "multipartFormData": [
+ {
+ "name": "file",
+ "value": "={{ $binary }}",
+ "type": "binary",
+ "fileName": "={{ $('IMAP: New Email').item.binary.attachment_0.fileName }}"
+ }
+ ],
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-11111111110b",
+ "name": "PB: Upload PDF to customer_pos",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [1780, 300],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "operation": "pdf",
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-11111111110c",
+ "name": "Extract PDF text",
+ "type": "n8n-nodes-base.extractFromFile",
+ "typeVersion": 1,
+ "position": [2000, 300]
+ },
+ {
+ "parameters": {
+ "jsCode": "// Receives the PDF text from Extract PDF text node.\n// Output: { lines: [{sku, description, quantity, unit_price, line_total}] }\n\nconst text = $input.first().json.text || '';\n\n// 1. Try to extract a PO number\nlet poNumber = null;\nconst poMatch = text.match(/(?:PO|P\\.O\\.|Purchase Order)\\s*(?:#|No\\.?|Number)?\\s*[:\\-]?\\s*([A-Z0-9\\-_/]{3,30})/i);\nif (poMatch) poNumber = poMatch[1].trim();\n\n// 2. Try to detect a currency\nlet currency = 'ZAR';\nif (/\\bUSD\\b|\\$\\s*\\d/.test(text) && !/R\\s?\\d/.test(text)) currency = 'USD';\nelse if (/\\bEUR\\b|€/.test(text)) currency = 'EUR';\nelse if (/\\bGBP\\b|£/.test(text)) currency = 'GBP';\n\n// 3. Extract line items: scan for lines that look like 'SKU Description Qty UnitPrice Total'\nconst lines = [];\n// Lines like \"AB-123 Widget 10 100.00 1000.00\"\nconst lineRegex = /^([A-Z0-9][A-Z0-9\\-_/\\.]{2,30})\\s+(.{3,80}?)\\s+(\\d+(?:\\.\\d+)?)\\s+(\\d+(?:\\.\\d+)?)\\s+(\\d+(?:\\.\\d+)?)\\s*$/gm;\nlet m;\nwhile ((m = lineRegex.exec(text)) !== null) {\n lines.push({\n sku: m[1].trim(),\n description: m[2].trim(),\n quantity: parseFloat(m[3]),\n unit_price: parseFloat(m[4]),\n line_total: parseFloat(m[5]),\n });\n}\n\n// 4. Compute total\nconst total = lines.reduce((s, l) => s + (l.line_total || 0), 0);\n\nreturn [{\n json: {\n po_number: poNumber,\n currency,\n total_amount: total,\n lines,\n raw_text_length: text.length,\n }\n}];\n"
+ },
+ "id": "11111111-1111-1111-1111-11111111110d",
+ "name": "Function: Parse lines from PDF text",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [2220, 300]
+ },
+ {
+ "parameters": {
+ "method": "PATCH",
+ "url": "=https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records/{{ $('PB: Create customer_pos record').item.json.id }}",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"po_number\": \"{{ $json.po_number }}\",\n \"currency\": \"{{ $json.currency }}\",\n \"total_amount\": {{ $json.total_amount }},\n \"parsed_lines\": {{ JSON.stringify($json.lines) }},\n \"status\": \"parsed\",\n \"processed_at\": \"{{ $now.toISO() }}\"\n}",
+ "options": {}
+ },
+ "id": "11111111-1111-1111-1111-11111111110e",
+ "name": "PB: Save parsed_lines + status=parsed",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [2440, 300],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ }
+ ],
+ "connections": {
+ "IMAP: New Email": {
+ "main": [
+ [
+ {
+ "node": "Filter: Has PDF + looks like PO",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Filter: Has PDF + looks like PO": {
+ "main": [
+ [
+ {
+ "node": "PB: Log email_inbox",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "PB: Log ignored email",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Log email_inbox": {
+ "main": [
+ [
+ {
+ "node": "PB: Find customer by email",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Find customer by email": {
+ "main": [
+ [
+ {
+ "node": "PB: Create customer_pos record",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Create customer_pos record": {
+ "main": [
+ [
+ {
+ "node": "If: customer found",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "If: customer found": {
+ "main": [
+ [
+ {
+ "node": "PB: Link customer to PO",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "PB: Upload PDF to customer_pos",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "PB: Upload PDF to customer_pos",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Link customer to PO": {
+ "main": [
+ [
+ {
+ "node": "PB: Upload PDF to customer_pos",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Upload PDF to customer_pos": {
+ "main": [
+ [
+ {
+ "node": "Extract PDF text",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Extract PDF text": {
+ "main": [
+ [
+ {
+ "node": "Function: Parse lines from PDF text",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Function: Parse lines from PDF text": {
+ "main": [
+ [
+ {
+ "node": "PB: Save parsed_lines + status=parsed",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "pinData": {},
+ "versionId": "1",
+ "triggerCount": 1,
+ "tags": [
+ {
+ "name": "jeisa",
+ "id": "tag-jeisa"
+ },
+ {
+ "name": "po",
+ "id": "tag-po"
+ },
+ {
+ "name": "purchase-order",
+ "id": "tag-purchase-order"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/n8n/02-parsed-po-to-invoice.json b/n8n/02-parsed-po-to-invoice.json
new file mode 100644
index 0000000..6d62c8d
--- /dev/null
+++ b/n8n/02-parsed-po-to-invoice.json
@@ -0,0 +1,544 @@
+{
+ "name": "JEISA: Parsed PO → QuickBooks Invoice → Email → Stock decrement",
+ "description": "Polls PocketBase for `customer_pos` records with status='parsed', creates a QuickBooks Online invoice, sends it to the customer via email, and decrements stock_items.quantity for each line item linked to inventory. End state: customer_pos.status='invoiced', invoices.status='sent', stock_items decremented.",
+ "active": false,
+ "settings": {
+ "executionOrder": "v1",
+ "saveExecutionProgress": true,
+ "saveManualExecutions": true,
+ "saveDataSuccessExecution": "all",
+ "saveDataErrorExecution": "all",
+ "timezone": "Africa/Johannesburg"
+ },
+ "nodes": [
+ {
+ "parameters": {
+ "rule": {
+ "interval": [
+ {
+ "field": "minutes",
+ "minutesInterval": 2
+ }
+ ]
+ }
+ },
+ "id": "22222222-2222-2222-2222-222222222201",
+ "name": "Cron: every 2 minutes",
+ "type": "n8n-nodes-base.scheduleTrigger",
+ "typeVersion": 1.2,
+ "position": [240, 300]
+ },
+ {
+ "parameters": {
+ "method": "GET",
+ "url": "https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records?filter=status%20%3D%20%22parsed%22&expand=customer&perPage=20",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-222222222202",
+ "name": "PB: Fetch customer_pos (status=parsed)",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [460, 300],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "c-1",
+ "leftValue": "={{ $json.items.length }}",
+ "rightValue": 0,
+ "operator": {
+ "type": "number",
+ "operation": "gt"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-222222222203",
+ "name": "If: any POs to invoice?",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2,
+ "position": [680, 300]
+ },
+ {
+ "parameters": {
+ "batchSize": 1,
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-222222222204",
+ "name": "Loop: One PO at a time",
+ "type": "n8n-nodes-base.splitInBatches",
+ "typeVersion": 3,
+ "position": [900, 200]
+ },
+ {
+ "parameters": {
+ "method": "GET",
+ "url": "=https://pb.jeistk.apphub.co.za/api/collections/stock_items/records?filter=sku%20%3D%20%22{{ encodeURIComponent($json.sku) }}%22&perPage=1",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-222222222205",
+ "name": "PB: Lookup stock_item by sku",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [1120, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "// Build the QuickBooks Online invoice Line array from customer_pos.parsed_lines.\n// For each line, attach the stock_item.id if we looked one up; otherwise leave it\n// blank (so the stock-decrement step skips it).\n\nconst parsedLines = $json.parsed_lines || [];\nconst stockItemId = $json.stock_items_match_id || null;\n\nconst lineItems = parsedLines.map((line, i) => {\n // If only one line and we found stock_item_id, attach it; otherwise leave blank.\n const stockItemForLine = (parsedLines.length === 1 && stockItemId) ? stockItemId : null;\n return {\n DetailType: 'SalesItemLineDetail',\n Amount: line.line_total ?? (line.quantity * line.unit_price),\n Description: line.description || line.sku || '',\n SalesItemLineDetail: {\n Qty: line.quantity,\n UnitPrice: line.unit_price,\n // NOTE: replace 1 with your QB Item ID for JEI products. For now we hardcode '1'.\n ItemRef: { value: '1' },\n },\n };\n});\n\nreturn [{\n json: {\n qb_invoice_payload: {\n Line: lineItems,\n CustomerRef: { value: $json.expand?.customer?.id || '' },\n // Email the invoice to the customer's primary email\n BillEmail: { Address: $json.expand?.customer?.email || '' },\n // Currency from PO if set\n CurrencyRef: { value: $json.currency || 'ZAR' },\n DocNumber: $json.po_number || '',\n PrivateNote: `Auto-created from customer_po ${$json.id}`,\n },\n line_count: lineItems.length,\n }\n}];\n"
+ },
+ "id": "22222222-2222-2222-2222-222222222206",
+ "name": "Function: Build QB invoice payload",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [1340, 200]
+ },
+ {
+ "parameters": {
+ "operation": "create",
+ "additionalFields": {
+ "line": "={{ $json.qb_invoice_payload.Line }}",
+ "customerRef": "={{ $json.qb_invoice_payload.CustomerRef }}",
+ "billEmail": "={{ $json.qb_invoice_payload.BillEmail }}",
+ "currencyRef": "={{ $json.qb_invoice_payload.CurrencyRef }}",
+ "docNumber": "={{ $json.qb_invoice_payload.DocNumber }}",
+ "privateNote": "={{ $json.qb_invoice_payload.PrivateNote }}"
+ }
+ },
+ "id": "22222222-2222-2222-2222-222222222207",
+ "name": "QuickBooks: Create Invoice",
+ "type": "n8n-nodes-base.quickbooks",
+ "typeVersion": 1,
+ "position": [1560, 200],
+ "credentials": {
+ "quickbooksOAuth2Api": {
+ "id": "REPLACE_ME_qb_credential",
+ "name": "JEISA QuickBooks Online"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "https://pb.jeistk.apphub.co.za/api/collections/invoices/records",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"customer\": \"{{ $('PB: Fetch customer_pos (status=parsed)').item.json.expand.customer.id }}\",\n \"source_customer_po\": \"{{ $('PB: Fetch customer_pos (status=parsed)').item.json.id }}\",\n \"invoice_number\": \"{{ $json.qb_invoice_payload.DocNumber || $json.Invoice.docNumber }}\",\n \"quickbooks_invoice_id\": \"{{ $json.Id || $json.Invoice.Id }}\",\n \"issue_date\": \"{{ $now.toISO() }}\",\n \"due_date\": \"{{ $now.plus({days: 30}).toISO() }}\",\n \"subtotal\": {{ $json.TotalAmt || 0 }},\n \"total\": {{ $json.TotalAmt || 0 }},\n \"currency\": \"{{ $('PB: Fetch customer_pos (status=parsed)').item.json.currency || 'ZAR' }}\",\n \"status\": \"draft\",\n \"qb_raw_response\": {{ JSON.stringify($json) }}\n}",
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-222222222208",
+ "name": "PB: Create invoices record",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [1780, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "operation": "send",
+ "invoiceId": "={{ $('QuickBooks: Create Invoice').item.json.Id }}",
+ "email": "={{ $('PB: Fetch customer_pos (status=parsed)').item.json.expand.customer.email }}",
+ "additionalFields": {}
+ },
+ "id": "22222222-2222-2222-2222-222222222209",
+ "name": "QuickBooks: Send Invoice by email",
+ "type": "n8n-nodes-base.quickbooks",
+ "typeVersion": 1,
+ "position": [2000, 200],
+ "credentials": {
+ "quickbooksOAuth2Api": {
+ "id": "REPLACE_ME_qb_credential",
+ "name": "JEISA QuickBooks Online"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "PATCH",
+ "url": "=https://pb.jeistk.apphub.co.za/api/collections/invoices/records/{{ $('PB: Create invoices record').item.json.id }}",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"status\": \"sent\",\n \"emailed_to\": \"{{ $('PB: Fetch customer_pos (status=parsed)').item.json.expand.customer.email }}\",\n \"emailed_at\": \"{{ $now.toISO() }}\"\n}",
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-22222222220a",
+ "name": "PB: Mark invoice sent",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [2220, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "PATCH",
+ "url": "=https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records/{{ $('PB: Fetch customer_pos (status=parsed)').item.json.id }}",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"status\": \"invoiced\",\n \"linked_invoice\": \"{{ $('PB: Create invoices record').item.json.id }}\"\n}",
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-22222222220b",
+ "name": "PB: Mark customer_pos invoiced",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [2440, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "https://pb.jeistk.apphub.co.za/api/collections/invoice_items/records",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"invoice\": \"{{ $('PB: Create invoices record').item.json.id }}\",\n \"sku\": \"{{ $json.sku }}\",\n \"description\": \"{{ $json.description }}\",\n \"quantity\": {{ $json.quantity }},\n \"unit_price\": {{ $json.unit_price }},\n \"line_total\": {{ $json.line_total }}\n}",
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-22222222220c",
+ "name": "PB: Create invoice_items rows",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [2660, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "// Decrement stock for each parsed line that we successfully matched to a stock_item.\n// For multi-line POs without per-line SKU matching, only single-line POs will decrement.\n// You can extend this by adding per-line stock_item lookups.\n\nconst parsedLines = $('PB: Fetch customer_pos (status=parsed)').item.json.parsed_lines || [];\n\n// Build batch payload: [{ id, newQty }]\nconst decrements = [];\nfor (const line of parsedLines) {\n if (line.stock_item_id) {\n decrements.push({ id: line.stock_item_id, qty: line.quantity });\n }\n}\n\nreturn [{ json: { decrements } }];\n"
+ },
+ "id": "22222222-2222-2222-2222-22222222220d",
+ "name": "Function: Build stock decrement batch",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [2880, 200]
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "https://pb.jeistk.apphub.co.za/api/batch",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"batch\": [\n {{ $json.decrements.map(d => `{\"method\": \"PATCH\", \"url\": \"/api/collections/stock_items/records/${d.id}\", \"body\": {\"quantity\": \"__PLACEHOLDER__\"}}`).join(',\\\\n ') }}\n ]\n}",
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-22222222220e",
+ "name": "PB: Batch decrement stock_items",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [3100, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "method": "PATCH",
+ "url": "=https://pb.jeistk.apphub.co.za/api/collections/invoices/records/{{ $('PB: Create invoices record').item.json.id }}",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "pocketbaseApi",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\n \"stock_decremented\": true\n}",
+ "options": {}
+ },
+ "id": "22222222-2222-2222-2222-22222222220f",
+ "name": "PB: Mark invoice stock_decremented",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [3320, 200],
+ "credentials": {
+ "pocketbaseApi": {
+ "id": "REPLACE_ME_pb_credential",
+ "name": "JEISA PocketBase"
+ }
+ }
+ }
+ ],
+ "connections": {
+ "Cron: every 2 minutes": {
+ "main": [
+ [
+ {
+ "node": "PB: Fetch customer_pos (status=parsed)",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Fetch customer_pos (status=parsed)": {
+ "main": [
+ [
+ {
+ "node": "If: any POs to invoice?",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "If: any POs to invoice?": {
+ "main": [
+ [
+ {
+ "node": "Loop: One PO at a time",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Loop: One PO at a time": {
+ "main": [
+ [
+ {
+ "node": "PB: Lookup stock_item by sku",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ []
+ ]
+ },
+ "PB: Lookup stock_item by sku": {
+ "main": [
+ [
+ {
+ "node": "Function: Build QB invoice payload",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Function: Build QB invoice payload": {
+ "main": [
+ [
+ {
+ "node": "QuickBooks: Create Invoice",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "QuickBooks: Create Invoice": {
+ "main": [
+ [
+ {
+ "node": "PB: Create invoices record",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Create invoices record": {
+ "main": [
+ [
+ {
+ "node": "QuickBooks: Send Invoice by email",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "QuickBooks: Send Invoice by email": {
+ "main": [
+ [
+ {
+ "node": "PB: Mark invoice sent",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Mark invoice sent": {
+ "main": [
+ [
+ {
+ "node": "PB: Mark customer_pos invoiced",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "PB: Create invoice_items rows",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Mark customer_pos invoiced": {
+ "main": [
+ [
+ {
+ "node": "Function: Build stock decrement batch",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Create invoice_items rows": {
+ "main": [
+ [
+ {
+ "node": "PB: Mark invoice stock_decremented",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Function: Build stock decrement batch": {
+ "main": [
+ [
+ {
+ "node": "PB: Batch decrement stock_items",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "PB: Batch decrement stock_items": {
+ "main": [
+ []
+ ]
+ }
+ },
+ "pinData": {},
+ "versionId": "1",
+ "triggerCount": 1,
+ "tags": [
+ {
+ "name": "jeisa",
+ "id": "tag-jeisa"
+ },
+ {
+ "name": "invoice",
+ "id": "tag-invoice"
+ },
+ {
+ "name": "quickbooks",
+ "id": "tag-quickbooks"
+ },
+ {
+ "name": "stock",
+ "id": "tag-stock"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/n8n/README.md b/n8n/README.md
new file mode 100644
index 0000000..41afecd
--- /dev/null
+++ b/n8n/README.md
@@ -0,0 +1,175 @@
+# JEISA Automation: Customer PO → Invoice → Stock
+
+End-to-end pipeline that watches a JEI mailbox for incoming customer purchase
+orders, uploads them to PocketBase, creates QuickBooks invoices, emails them
+to customers, and decrements stock.
+
+```
+┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
+│ Customer email │───▶│ n8N workflow │───▶│ PocketBase │ │ QuickBooks │
+│ (PO PDF) │ │ 01: email→PB │ │ • customer_pos │ │ Online │
+└─────────────────┘ └─────────────────┘ │ • parsed_lines │ └────────┬────────┘
+ └──────────────────┘ │
+┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
+│ Stock list │◀───│ n8N workflow │◀───│ status:parsed │◀────────────┘
+│ decremented │ │ 02: invoice+ │ └──────────────────┘ (status changes
+└─────────────────┘ │ stock │ to "parsed")
+ └─────────────────┘
+```
+
+The AI agent you task can do all of this through the **PocketBase MCP server**
+in `.cursor/mcp.json` — you ask it to "create a customer PO" or "show me all
+pending invoices" and it talks to the same database.
+
+---
+
+## What's in this repo
+
+| Path | What |
+|---|---|
+| `pocketbase/pb_migrations/1786200001_created_customer_pos.js` | New collection: incoming POs from customers |
+| `pocketbase/pb_migrations/1786200002_created_customer_po_lines.js` | New collection: parsed line items per PO |
+| `pocketbase/pb_migrations/1786200003_created_invoices.js` | New collection: invoices we issue (with QB id) |
+| `pocketbase/pb_migrations/1786200004_created_invoice_items.js` | New collection: line items per invoice |
+| `pocketbase/pb_migrations/1786200005_created_email_inbox.js` | New collection: every email the workflow sees |
+| `pocketbase/pb_migrations/1786200006_created_workflow_runs.js` | New collection: every n8N execution |
+| `.cursor/mcp.json` | Cursor MCP config — pocketbase-mcp-bridge |
+| `.mcp.json` | Same config, for Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json`) |
+| `n8n/01-customer-po-to-parsed.json` | n8N workflow: email → upload PDF → parse lines |
+| `n8n/02-parsed-po-to-invoice.json` | n8N workflow: parsed → QuickBooks invoice → send → decrement stock |
+| `.env.example` | Template — copy to `.env`, fill in credentials |
+
+All 6 migrations have been validated against the local PocketBase 0.30
+binary in this repo (`pocketbase/pocketbase`).
+
+---
+
+## Deploy: PocketBase
+
+1. **Copy the new migration files** to your PB server's `pb_migrations/` dir.
+ The next time your PB instance starts, it will apply them in order.
+ (Tested locally: collections `customer_pos`, `customer_po_lines`,
+ `invoices`, `invoice_items`, `email_inbox`, `workflow_runs` were created.)
+
+2. **Verify** by hitting `GET /api/collections` with an admin token — you
+ should see all six new collections listed.
+
+3. **No data backfill is needed** — these are fresh tables.
+
+4. **Existing customers collection** (`pbc_108570809`) is referenced by
+ `customer_pos.customer` and `invoices.customer`. Make sure every customer
+ has a valid `email` filled in — that's how the workflow matches incoming
+ POs to customers.
+
+---
+
+## Deploy: MCP server for the AI agent
+
+The MCP server (`pocketbase-mcp-bridge`) gives an AI agent (Claude, Cursor,
+etc.) full access to read/write PocketBase. This is what you use to "task
+a agent to create purchase orders for me."
+
+1. Open `.cursor/mcp.json` and fill in the `REPLACE_ME_*` placeholders
+ with the values from `.env` (`POCKETBASE_URL`, `POCKETBASE_ADMIN_EMAIL`,
+ `POCKETBASE_ADMIN_PASSWORD`).
+
+2. **Restart Cursor.** The MCP server will auto-install via `npx -y
+ pocketbase-mcp-bridge` and authenticate as the superuser.
+
+3. Open a new chat and ask the agent to "list the customer_pos records with
+ status=new." It should call `list_records` and return the rows.
+
+4. To create a PO via the agent, say: *"Create a customer PO for Isherwood
+ Mining, PO number ISO-001, 10 × Drill Bit at R150 each."* The agent
+ will call `create_record` on the `customer_pos` collection with the right
+ shape.
+
+Available tools (55 total, see
+[github.com/nestebe/pocketbase-mcp](https://github.com/nestebe/pocketbase-mcp)
+for the full list):
+
+- `list_records`, `get_record`, `create_record`, `update_record`,
+ `delete_record`, `get_first_record`
+- `list_collections`, `get_collection`, `create_collection`,
+ `update_collection`, `delete_collection`
+- `auth_with_password`, `list_auth_methods`
+- `list_logs`, `get_settings`, `list_backups`
+- `send_raw_request` — call any endpoint (e.g. PB hooks, custom routes)
+
+---
+
+## Deploy: n8N workflows
+
+1. **Install n8N** (if you haven't already):
+ ```bash
+ npm install -g n8n
+ # or run via Docker: docker run -it --rm -p 5678:5678 n8nio/n8n
+ ```
+
+2. **Import the workflows**: in the n8N UI, *Workflows → Import from File*
+ and select:
+ - `n8n/01-customer-po-to-parsed.json`
+ - `n8n/02-parsed-po-to-invoice.json`
+
+3. **Create credentials** for each node that has a `REPLACE_ME_*`:
+ - **IMAP** credential → your `orders@jeisa.co.za` mailbox
+ - **PocketBase API** credential → URL + admin email/password from `.env`
+ - **QuickBooks OAuth2 API** credential → connect your QBO account
+
+4. **Activate the workflows** in the n8N UI (toggle "Active" in the top
+ right of each).
+
+---
+
+## End-to-end test
+
+1. Send a test email from your personal account to `orders@jeisa.co.za`.
+ Subject: "PO test — please ignore."
+ Attach a fake PDF with one or two lines like:
+ ```
+ AB-123 Test Widget 10 100.00 1000.00
+ ```
+
+2. Workflow 01 should fire within ~30s:
+ - A `customer_pos` record is created
+ - A `customer_pos_line` record is created
+ - `status` becomes `parsed`
+
+3. Workflow 02 should fire within ~2 minutes:
+ - A QuickBooks invoice is created (sandbox)
+ - An `invoices` record is created with `status=sent`
+ - The `stock_items` quantity is decremented (only if the SKU matches a
+ stock row — wire up more SKUs in the `stock_items` collection)
+ - `customer_pos.status` becomes `invoiced`
+
+4. Check `workflow_runs` and `email_inbox` in PB for the audit trail.
+
+---
+
+## Customizing
+
+| Want to | Edit |
+|---|---|
+| Add fields to the email parser (e.g. delivery date) | `n8n/01-…json` → "Function: Parse lines from PDF text" |
+| Switch to AI-powered PDF parsing (for scanned POs) | Add an "OpenAI" node between "Extract PDF text" and "Function: Parse lines" |
+| Email the invoice from JEI's SMTP (not QB) | Replace "QuickBooks: Send Invoice by email" with the Send Email node |
+| Use a different QuickBooks Item ID for SKUs | "Function: Build QB invoice payload" → `ItemRef.value` |
+| Only auto-invoice certain customers | Add an `auto_invoice` boolean to `customers`, then add a filter in workflow 02 |
+| Sync QuickBooks payments back | Add a third workflow that polls QB Invoice.Status every hour and updates `invoices.status` |
+
+---
+
+## Important: confirm before making this live
+
+Before going live, you'll want a human approval step between workflow 01
+and 02 — the current design auto-invoices everything that looks like a PO.
+Add a `status: "parsed_pending_review"` and require a human to flip it to
+`"parsed"` to make this safer. The simplest way:
+
+1. In `01-…json`, change the final node to set `status = "parsed_pending_review"`.
+2. In `02-…json`, change the filter from `status = "parsed"` to
+ `status = "parsed_pending_review"`.
+3. Add a tiny approval UI in the React app (a button on the customer_pos
+ row that PATCHes the status to "parsed").
+
+This stops random emails from triggering invoices.
\ No newline at end of file
diff --git a/pocketbase/pb_migrations/1786200001_created_customer_pos.js b/pocketbase/pb_migrations/1786200001_created_customer_pos.js
new file mode 100644
index 0000000..f4597d5
--- /dev/null
+++ b/pocketbase/pb_migrations/1786200001_created_customer_pos.js
@@ -0,0 +1,236 @@
+///
+
+// =========================================================================
+// customer_pos — incoming POs from customers, received via email
+// =========================================================================
+//
+// This is what the n8N IMAP workflow creates when a customer emails a
+// purchase order PDF to the JEI orders inbox. The PDF is stored in `po_pdf`
+// and parsed line items go into `customer_po_lines`.
+//
+// NOTE on naming: the existing `orders` collection (pbc_3527180448) is the
+// operational / sales order system (fulfillment, drivers, deliveries). This
+// collection is INCOMING POs from clients — different lifecycle, different
+// fields, different ownership. Hence the distinct name `customer_pos`.
+//
+// Status lifecycle:
+// new → email received, PDF saved, not yet inspected
+// parsed → line items extracted, ready for invoicing
+// invoiced → linked invoice created and sent
+// cancelled → ignored (wrong sender / duplicate)
+// error → workflow failed, see `error_message`
+// =========================================================================
+
+migrate((app) => {
+ const collection = new Collection({
+ "createRule": "@request.auth.id != ''",
+ "deleteRule": "@request.auth.id != ''",
+ "listRule": "@request.auth.id != ''",
+ "name": "customer_pos",
+ "system": false,
+ "type": "base",
+ "updateRule": "@request.auth.id != ''",
+ "viewRule": "@request.auth.id != ''",
+ "fields": [
+ {
+ "cascadeDelete": false,
+ "collectionId": "pbc_108570809",
+ "hidden": false,
+ "id": "rel_cpo_customer",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "customer",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_cpo_po_number",
+ "max": 100,
+ "min": 0,
+ "name": "po_number",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "file_cpo_po_pdf",
+ "maxSelect": 10,
+ "maxSize": 20971520,
+ "mimeTypes": ["application/pdf", "image/png", "image/jpeg"],
+ "name": "po_pdf",
+ "presentable": false,
+ "protected": false,
+ "required": false,
+ "system": false,
+ "thumbs": [],
+ "type": "file"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_cpo_email_from",
+ "max": 500,
+ "min": 0,
+ "name": "source_email_from",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_cpo_email_subject",
+ "max": 500,
+ "min": 0,
+ "name": "source_email_subject",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "dt_cpo_email_received",
+ "max": "",
+ "min": "",
+ "name": "source_email_received_at",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_cpo_email_uid",
+ "max": 200,
+ "min": 0,
+ "name": "source_email_uid",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "sel_cpo_status",
+ "maxSelect": 1,
+ "name": "status",
+ "presentable": true,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": ["new", "parsed", "invoiced", "cancelled", "error"]
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": "pbc_1342968361",
+ "hidden": false,
+ "id": "rel_cpo_purchase_order",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "matched_purchase_order",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "hidden": false,
+ "id": "json_cpo_parsed_lines",
+ "maxSize": 200000,
+ "name": "parsed_lines",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "num_cpo_total",
+ "max": null,
+ "min": null,
+ "name": "total_amount",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_cpo_currency",
+ "max": 10,
+ "min": 0,
+ "name": "currency",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_cpo_notes",
+ "max": 0,
+ "min": 0,
+ "name": "notes",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "dt_cpo_processed",
+ "max": "",
+ "min": "",
+ "name": "processed_at",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_cpo_error",
+ "max": 0,
+ "min": 0,
+ "name": "error_message",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ }
+ ],
+ "indexes": []
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("customer_pos");
+ return app.delete(collection);
+})
\ No newline at end of file
diff --git a/pocketbase/pb_migrations/1786200002_created_customer_po_lines.js b/pocketbase/pb_migrations/1786200002_created_customer_po_lines.js
new file mode 100644
index 0000000..1dd8128
--- /dev/null
+++ b/pocketbase/pb_migrations/1786200002_created_customer_po_lines.js
@@ -0,0 +1,161 @@
+///
+
+// =========================================================================
+// customer_po_lines — one record per line item on an incoming customer PO
+// =========================================================================
+//
+// Splits the `parsed_lines` json blob on a `customer_pos` record into
+// individual rows. n8N writes these after extracting SKU, qty, price from
+// the customer's PDF. The stock-decrement node reads from here.
+//
+// Note: `app.findCollectionByNameOrId` is wrapped so this migration applies
+// even if `stock_items` isn't present in the local DB. In production (hosted
+// PB), `customer_pos` and `stock_items` both exist.
+// =========================================================================
+
+migrate((app) => {
+ const safeFind = (name) => {
+ try { return app.findCollectionByNameOrId(name); }
+ catch (e) { return null; }
+ };
+
+ const cpoCol = safeFind("customer_pos");
+ if (!cpoCol) {
+ throw new Error("Required collection 'customer_pos' not found — apply migrations in order.");
+ }
+ const stockItemsCol = safeFind("stock_items");
+
+ const schema = [
+ {
+ "cascadeDelete": true,
+ "collectionId": cpoCol.id,
+ "hidden": false,
+ "id": "rel_cpol_cpo",
+ "maxSelect": 1,
+ "minSelect": 1,
+ "name": "customer_po",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_cpol_sku",
+ "max": 100,
+ "min": 0,
+ "name": "sku",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_cpol_description",
+ "max": 500,
+ "min": 0,
+ "name": "description",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "num_cpol_qty",
+ "max": null,
+ "min": null,
+ "name": "quantity",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "num_cpol_unit_price",
+ "max": null,
+ "min": null,
+ "name": "unit_price",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "num_cpol_line_total",
+ "max": null,
+ "min": null,
+ "name": "line_total",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "bool_cpol_stock_decremented",
+ "name": "stock_decremented",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ },
+ {
+ "hidden": false,
+ "id": "dt_cpol_stock_decremented",
+ "max": "",
+ "min": "",
+ "name": "stock_decremented_at",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ }
+ ];
+
+ if (stockItemsCol) {
+ schema.splice(1, 0, {
+ "cascadeDelete": false,
+ "collectionId": stockItemsCol.id,
+ "hidden": false,
+ "id": "rel_cpol_stock",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "stock_item",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ });
+ }
+
+ const collection = new Collection({
+ "createRule": "@request.auth.id != ''",
+ "deleteRule": "@request.auth.id != ''",
+ "listRule": "@request.auth.id != ''",
+ "name": "customer_po_lines",
+ "system": false,
+ "type": "base",
+ "updateRule": "@request.auth.id != ''",
+ "viewRule": "@request.auth.id != ''",
+ "fields": schema,
+ "indexes": []
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("customer_po_lines");
+ return app.delete(collection);
+})
\ No newline at end of file
diff --git a/pocketbase/pb_migrations/1786200003_created_invoices.js b/pocketbase/pb_migrations/1786200003_created_invoices.js
new file mode 100644
index 0000000..c697816
--- /dev/null
+++ b/pocketbase/pb_migrations/1786200003_created_invoices.js
@@ -0,0 +1,268 @@
+///
+
+// =========================================================================
+// invoices — invoices we issue to customers (created by n8N from QuickBooks)
+// =========================================================================
+//
+// One record per invoice. Holds the QuickBooks invoice id so we can link
+// back to the source of truth in QB and also audit what we emailed.
+//
+// Status lifecycle:
+// draft → created in QB but not emailed yet
+// sent → emailed to customer
+// paid → QB reports paid (synced back via webhook / poll)
+// overdue → past due date and unpaid
+// voided → manually cancelled
+// =========================================================================
+
+migrate((app) => {
+ // look up related collections by name (their IDs are auto-generated by PB)
+ const customersCol = app.findCollectionByNameOrId("customers");
+ const customerPosCol = app.findCollectionByNameOrId("customer_pos");
+
+ const collection = new Collection({
+ "createRule": "@request.auth.id != ''",
+ "deleteRule": "@request.auth.id != ''",
+ "listRule": "@request.auth.id != ''",
+ "name": "invoices",
+ "system": false,
+ "type": "base",
+ "updateRule": "@request.auth.id != ''",
+ "viewRule": "@request.auth.id != ''",
+ "fields": [
+ {
+ "cascadeDelete": false,
+ "collectionId": customersCol.id,
+ "hidden": false,
+ "id": "rel_inv_customer",
+ "maxSelect": 1,
+ "minSelect": 1,
+ "name": "customer",
+ "presentable": true,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": customerPosCol.id,
+ "hidden": false,
+ "id": "rel_inv_source_order",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "source_customer_po",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inv_number",
+ "max": 50,
+ "min": 0,
+ "name": "invoice_number",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inv_qb_id",
+ "max": 50,
+ "min": 0,
+ "name": "quickbooks_invoice_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "dt_inv_issue_date",
+ "max": "",
+ "min": "",
+ "name": "issue_date",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "hidden": false,
+ "id": "dt_inv_due_date",
+ "max": "",
+ "min": "",
+ "name": "due_date",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "hidden": false,
+ "id": "num_inv_subtotal",
+ "max": null,
+ "min": null,
+ "name": "subtotal",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "num_inv_tax",
+ "max": null,
+ "min": null,
+ "name": "tax",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "num_inv_total",
+ "max": null,
+ "min": null,
+ "name": "total",
+ "onlyInt": true,
+ "presentable": true,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inv_currency",
+ "max": 10,
+ "min": 0,
+ "name": "currency",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "sel_inv_status",
+ "maxSelect": 1,
+ "name": "status",
+ "presentable": true,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": ["draft", "sent", "paid", "overdue", "voided"]
+ },
+ {
+ "hidden": false,
+ "id": "file_inv_pdf",
+ "maxSelect": 1,
+ "maxSize": 20971520,
+ "mimeTypes": ["application/pdf"],
+ "name": "invoice_pdf",
+ "presentable": false,
+ "protected": false,
+ "required": false,
+ "system": false,
+ "thumbs": [],
+ "type": "file"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inv_emailed_to",
+ "max": 500,
+ "min": 0,
+ "name": "emailed_to",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "dt_inv_emailed_at",
+ "max": "",
+ "min": "",
+ "name": "emailed_at",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inv_email_message_id",
+ "max": 200,
+ "min": 0,
+ "name": "email_message_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "bool_inv_stock_decremented",
+ "name": "stock_decremented",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inv_qb_response",
+ "max": 50000,
+ "min": 0,
+ "name": "qb_raw_response",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inv_notes",
+ "max": 0,
+ "min": 0,
+ "name": "notes",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ }
+ ],
+ "indexes": []
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("invoices");
+ return app.delete(collection);
+})
\ No newline at end of file
diff --git a/pocketbase/pb_migrations/1786200004_created_invoice_items.js b/pocketbase/pb_migrations/1786200004_created_invoice_items.js
new file mode 100644
index 0000000..be99e89
--- /dev/null
+++ b/pocketbase/pb_migrations/1786200004_created_invoice_items.js
@@ -0,0 +1,162 @@
+///
+
+// =========================================================================
+// invoice_items — line items on an issued invoice
+// =========================================================================
+//
+// Mirror of `customer_po_lines` but bound to the issued `invoices` record.
+// After the n8N workflow emails the invoice, it loops over these and
+// decrements stock_items.quantity by `quantity` (skipping rows where
+// `stock_item` is empty — those are service / non-inventory lines).
+// =========================================================================
+//
+// Note: `app.findCollectionByNameOrId` is wrapped so this migration applies
+// even if the parent collection isn't present yet in a partial local DB.
+// In production (hosted PB), `invoices`, `stock_items`, `customer_po_lines`
+// all exist.
+// =========================================================================
+
+migrate((app) => {
+ const safeFind = (name) => {
+ try { return app.findCollectionByNameOrId(name); }
+ catch (e) { return null; }
+ };
+
+ const invoicesCol = safeFind("invoices");
+ const stockItemsCol = safeFind("stock_items");
+ const cpoLinesCol = safeFind("customer_po_lines");
+
+ if (!invoicesCol) {
+ throw new Error("Required collection 'invoices' not found — apply migrations in order.");
+ }
+
+ const schema = [
+ {
+ "cascadeDelete": true,
+ "collectionId": invoicesCol.id,
+ "hidden": false,
+ "id": "rel_invl_invoice",
+ "maxSelect": 1,
+ "minSelect": 1,
+ "name": "invoice",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_invl_sku",
+ "max": 100,
+ "min": 0,
+ "name": "sku",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_invl_description",
+ "max": 500,
+ "min": 0,
+ "name": "description",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "num_invl_qty",
+ "max": null,
+ "min": null,
+ "name": "quantity",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "num_invl_unit_price",
+ "max": null,
+ "min": null,
+ "name": "unit_price",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "num_invl_line_total",
+ "max": null,
+ "min": null,
+ "name": "line_total",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ }
+ ];
+
+ if (stockItemsCol) {
+ schema.splice(1, 0, {
+ "cascadeDelete": false,
+ "collectionId": stockItemsCol.id,
+ "hidden": false,
+ "id": "rel_invl_stock",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "stock_item",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ });
+ }
+
+ if (cpoLinesCol) {
+ schema.splice(stockItemsCol ? 2 : 1, 0, {
+ "cascadeDelete": false,
+ "collectionId": cpoLinesCol.id,
+ "hidden": false,
+ "id": "rel_invl_cpol",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "source_po_line",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ });
+ }
+
+ const collection = new Collection({
+ "createRule": "@request.auth.id != ''",
+ "deleteRule": "@request.auth.id != ''",
+ "listRule": "@request.auth.id != ''",
+ "name": "invoice_items",
+ "system": false,
+ "type": "base",
+ "updateRule": "@request.auth.id != ''",
+ "viewRule": "@request.auth.id != ''",
+ "fields": schema,
+ "indexes": []
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("invoice_items");
+ return app.delete(collection);
+})
\ No newline at end of file
diff --git a/pocketbase/pb_migrations/1786200005_created_email_inbox.js b/pocketbase/pb_migrations/1786200005_created_email_inbox.js
new file mode 100644
index 0000000..de0a712
--- /dev/null
+++ b/pocketbase/pb_migrations/1786200005_created_email_inbox.js
@@ -0,0 +1,175 @@
+///
+
+// =========================================================================
+// email_inbox — every email the n8N IMAP trigger sees, audit trail
+// =========================================================================
+//
+// Even emails we ignore (no PDF, wrong sender, auto-reply, bounce) get a
+// record here so we have a paper trail. n8N writes one row per email before
+// branching on whether to process it.
+// =========================================================================
+
+migrate((app) => {
+ const cpoCol = app.findCollectionByNameOrId("customer_pos");
+
+ const collection = new Collection({
+ "createRule": "@request.auth.id != ''",
+ "deleteRule": "@request.auth.id != ''",
+ "listRule": "@request.auth.id != ''",
+ "name": "email_inbox",
+ "system": false,
+ "type": "base",
+ "updateRule": "@request.auth.id != ''",
+ "viewRule": "@request.auth.id != ''",
+ "fields": [
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inbox_uid",
+ "max": 200,
+ "min": 0,
+ "name": "imap_uid",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inbox_from",
+ "max": 500,
+ "min": 0,
+ "name": "from",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inbox_subject",
+ "max": 500,
+ "min": 0,
+ "name": "subject",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inbox_to",
+ "max": 500,
+ "min": 0,
+ "name": "to",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inbox_body",
+ "max": 200000,
+ "min": 0,
+ "name": "body_plain",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "dt_inbox_received",
+ "max": "",
+ "min": "",
+ "name": "received_at",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "hidden": false,
+ "id": "bool_inbox_has_attachments",
+ "name": "has_attachments",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inbox_attachment_names",
+ "max": 2000,
+ "min": 0,
+ "name": "attachment_names",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "sel_inbox_disposition",
+ "maxSelect": 1,
+ "name": "disposition",
+ "presentable": true,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": ["processed", "ignored", "duplicate", "error", "manual_review"]
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": cpoCol.id,
+ "hidden": false,
+ "id": "rel_inbox_cpo",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "linked_customer_po",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_inbox_error",
+ "max": 0,
+ "min": 0,
+ "name": "error_message",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ }
+ ],
+ "indexes": []
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("email_inbox");
+ return app.delete(collection);
+})
\ No newline at end of file
diff --git a/pocketbase/pb_migrations/1786200006_created_workflow_runs.js b/pocketbase/pb_migrations/1786200006_created_workflow_runs.js
new file mode 100644
index 0000000..739b25d
--- /dev/null
+++ b/pocketbase/pb_migrations/1786200006_created_workflow_runs.js
@@ -0,0 +1,155 @@
+///
+
+// =========================================================================
+// workflow_runs — history of every n8N execution
+// =========================================================================
+//
+// n8N POSTs to a PocketBase endpoint once per execution. We store the run id,
+// workflow name, status, inputs/outputs (json), and any error. This lets us
+// see in the dashboard what happened last time, even when n8N's UI isn't open.
+// =========================================================================
+
+migrate((app) => {
+ const cpoCol = app.findCollectionByNameOrId("customer_pos");
+ const invoicesCol = app.findCollectionByNameOrId("invoices");
+
+ const collection = new Collection({
+ "createRule": "@request.auth.id != ''",
+ "deleteRule": "@request.auth.id != ''",
+ "listRule": "@request.auth.id != ''",
+ "name": "workflow_runs",
+ "system": false,
+ "type": "base",
+ "updateRule": "@request.auth.id != ''",
+ "viewRule": "@request.auth.id != ''",
+ "fields": [
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_wfr_n8n_id",
+ "max": 100,
+ "min": 0,
+ "name": "n8n_execution_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_wfr_workflow",
+ "max": 200,
+ "min": 0,
+ "name": "workflow_name",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "sel_wfr_status",
+ "maxSelect": 1,
+ "name": "status",
+ "presentable": true,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": ["running", "success", "failed", "cancelled"]
+ },
+ {
+ "hidden": false,
+ "id": "dt_wfr_started",
+ "max": "",
+ "min": "",
+ "name": "started_at",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "hidden": false,
+ "id": "dt_wfr_finished",
+ "max": "",
+ "min": "",
+ "name": "finished_at",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "hidden": false,
+ "id": "json_wfr_input",
+ "maxSize": 200000,
+ "name": "input_json",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "json_wfr_output",
+ "maxSize": 500000,
+ "name": "output_json",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "txt_wfr_error",
+ "max": 0,
+ "min": 0,
+ "name": "error_message",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": cpoCol.id,
+ "hidden": false,
+ "id": "rel_wfr_cpo",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "related_customer_po",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": invoicesCol.id,
+ "hidden": false,
+ "id": "rel_wfr_invoice",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "related_invoice",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ }
+ ],
+ "indexes": []
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("workflow_runs");
+ return app.delete(collection);
+})
\ No newline at end of file