{
  "name": "Customer layer: sync Stripe, Zendesk and HubSpot",
  "nodes": [
    {
      "id": "ca59da73-a407-586b-be13-7d2910fb3024",
      "name": "Every 30 minutes (manual for the demo)",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        300
      ],
      "parameters": {}
    },
    {
      "id": "d350746c-7071-5cf9-b373-b45aefb7192e",
      "name": "Stripe: subscriptions",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        260,
        0
      ],
      "parameters": {
        "url": "http://127.0.0.1:8787/stripe/v1/subscriptions?status=all",
        "options": {}
      }
    },
    {
      "id": "1a63f542-0588-53ca-994a-cd9dc33e4c1a",
      "name": "Subscriptions to rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        0
      ],
      "parameters": {
        "jsCode": "\n// Monthly recurring revenue per subscription, in euros.\nconst months = { month: 1, year: 12, week: 12 / 52, day: 12 / 365 };\nconst rows = $input.first().json.data.map(s => ({\n  id: s.id,\n  customer: s.customer,\n  status: s.status,\n  mrr_eur: s.items.data.reduce((sum, i) =>\n    sum + i.price.unit_amount / 100 * i.quantity\n        / (months[i.price.recurring.interval] * i.price.recurring.interval_count), 0),\n}));\nreturn [{ json: { rows } }];\n"
      }
    },
    {
      "id": "5b379a16-f838-5a67-8fe1-a4750a05f196",
      "name": "Save subscriptions",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        780,
        0
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO stripe_subscriptions (id, customer, status, mrr_eur)\nSELECT id, customer, status, mrr_eur\nFROM json_to_recordset($1::json) AS r(id text, customer text, status text, mrr_eur numeric)\nON CONFLICT (id) DO UPDATE\nSET customer = EXCLUDED.customer, status = EXCLUDED.status, mrr_eur = EXCLUDED.mrr_eur, synced_at = now();",
        "options": {
          "queryReplacement": "={{ [JSON.stringify($json.rows)] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "pgBriefDb",
          "name": "Customer layer (Postgres)"
        }
      }
    },
    {
      "id": "678287ae-2b02-5670-80bb-94aa2c344f36",
      "name": "Stripe: open invoices",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        260,
        150
      ],
      "parameters": {
        "url": "http://127.0.0.1:8787/stripe/v1/invoices?status=open",
        "options": {}
      }
    },
    {
      "id": "ed2eed63-5a4d-52bb-a19b-815ce523717c",
      "name": "Invoices to rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        150
      ],
      "parameters": {
        "jsCode": "\nconst rows = $input.first().json.data.map(i => ({\n  id: i.id, customer: i.customer, number: i.number, status: i.status,\n  amount_due_eur: i.amount_due / 100,\n  due_date: i.due_date ? new Date(i.due_date * 1000).toISOString().slice(0, 10) : null,\n}));\nreturn [{ json: { rows } }];\n"
      }
    },
    {
      "id": "14f7f1ea-b7fc-5f3b-89c4-e5d1189926bb",
      "name": "Save invoices",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        780,
        150
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH incoming AS (\n  SELECT * FROM json_to_recordset($1::json)\n    AS r(id text, customer text, number text, status text, amount_due_eur numeric, due_date date)\n), upserted AS (\n  INSERT INTO stripe_invoices (id, customer, number, status, amount_due_eur, due_date)\n  SELECT * FROM incoming\n  ON CONFLICT (id) DO UPDATE\n  SET status = EXCLUDED.status, amount_due_eur = EXCLUDED.amount_due_eur,\n      due_date = EXCLUDED.due_date, synced_at = now()\n  RETURNING id\n)\n-- An invoice that is no longer open in Stripe has been paid or voided.\nUPDATE stripe_invoices SET status = 'closed', synced_at = now()\nWHERE status = 'open' AND id NOT IN (SELECT id FROM incoming);",
        "options": {
          "queryReplacement": "={{ [JSON.stringify($json.rows)] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "pgBriefDb",
          "name": "Customer layer (Postgres)"
        }
      }
    },
    {
      "id": "f06e6733-1f43-53ab-bc35-8201a1c8a1dc",
      "name": "Zendesk: unsolved tickets",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        260,
        300
      ],
      "parameters": {
        "url": "http://127.0.0.1:8787/zendesk/api/v2/search.json?query=type:ticket%20status%3Csolved",
        "options": {}
      }
    },
    {
      "id": "ac681ced-cdba-5ca4-ab73-99a81334008f",
      "name": "Tickets to rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        300
      ],
      "parameters": {
        "jsCode": "\nconst rows = $input.first().json.results.map(t => ({\n  id: t.id, organization_id: 'org_' + t.organization_id, subject: t.subject,\n  status: t.status, priority: t.priority, created_at: t.created_at,\n  last_agent_reply_at: t.last_public_agent_reply_at, tags: t.tags,\n}));\nreturn [{ json: { rows } }];\n"
      }
    },
    {
      "id": "f76e05e9-743b-533e-8324-f47842c3a392",
      "name": "Save tickets",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        780,
        300
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH incoming AS (\n  SELECT * FROM json_to_recordset($1::json)\n    AS r(id bigint, organization_id text, subject text, status text, priority text,\n         created_at timestamptz, last_agent_reply_at timestamptz, tags text[])\n), upserted AS (\n  INSERT INTO zendesk_tickets (id, organization_id, subject, status, priority, created_at, last_agent_reply_at, tags)\n  SELECT * FROM incoming\n  ON CONFLICT (id) DO UPDATE\n  SET status = EXCLUDED.status, priority = EXCLUDED.priority, subject = EXCLUDED.subject,\n      last_agent_reply_at = EXCLUDED.last_agent_reply_at, tags = EXCLUDED.tags, synced_at = now()\n  RETURNING id\n)\n-- Tickets that dropped out of the unsolved search were solved.\nUPDATE zendesk_tickets SET status = 'solved', synced_at = now()\nWHERE status IN ('new', 'open', 'pending') AND id NOT IN (SELECT id FROM incoming);",
        "options": {
          "queryReplacement": "={{ [JSON.stringify($json.rows)] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "pgBriefDb",
          "name": "Customer layer (Postgres)"
        }
      }
    },
    {
      "id": "bcc7c8bb-9909-5b23-b7bc-81e737f05ac1",
      "name": "HubSpot: companies",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        260,
        450
      ],
      "parameters": {
        "url": "http://127.0.0.1:8787/hubspot/crm/v3/objects/companies?properties=name,contract_mrr,renewal_date,notice_period_days",
        "options": {}
      }
    },
    {
      "id": "40645f34-816e-5a93-92ff-058651f196ef",
      "name": "Companies to rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        450
      ],
      "parameters": {
        "jsCode": "\nconst rows = $input.first().json.results.map(c => ({\n  id: c.id, name: c.properties.name,\n  contract_mrr_eur: Number(c.properties.contract_mrr) || null,\n  renewal_date: c.properties.renewal_date || null,\n  notice_days: Number(c.properties.notice_period_days) || null,\n}));\nreturn [{ json: { rows } }];\n"
      }
    },
    {
      "id": "cfa92fad-bbff-5cac-ad13-a402818ab243",
      "name": "Save companies",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        780,
        450
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO hubspot_companies (id, name, contract_mrr_eur, renewal_date, notice_days)\nSELECT * FROM json_to_recordset($1::json)\n  AS r(id text, name text, contract_mrr_eur numeric, renewal_date date, notice_days int)\nON CONFLICT (id) DO UPDATE\nSET name = EXCLUDED.name, contract_mrr_eur = EXCLUDED.contract_mrr_eur,\n    renewal_date = EXCLUDED.renewal_date, notice_days = EXCLUDED.notice_days, synced_at = now();",
        "options": {
          "queryReplacement": "={{ [JSON.stringify($json.rows)] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "pgBriefDb",
          "name": "Customer layer (Postgres)"
        }
      }
    },
    {
      "id": "cf3ecf79-8976-506f-842b-8089fd574898",
      "name": "HubSpot: notes and calls",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        260,
        600
      ],
      "parameters": {
        "url": "http://127.0.0.1:8787/hubspot/crm/v3/objects/notes?associations=companies",
        "options": {}
      }
    },
    {
      "id": "3b45149a-04fd-5e11-ade7-071f934601ae",
      "name": "Notes to rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        600
      ],
      "parameters": {
        "jsCode": "\nconst rows = $input.first().json.results.flatMap(n =>\n  (n.associations?.companies?.results ?? []).map(c => ({\n    id: n.id + ':' + c.id, company_id: c.id, type: n.properties.type,\n    occurred_at: n.properties.hs_timestamp, logged_by: n.properties.logged_by,\n    summary: n.properties.hs_note_body,\n  })));\nreturn [{ json: { rows } }];\n"
      }
    },
    {
      "id": "abbbfdd9-cb85-5e36-9632-f2b2571ee5d6",
      "name": "Save notes",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        780,
        600
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO hubspot_activities (id, company_id, type, occurred_at, logged_by, summary)\nSELECT * FROM json_to_recordset($1::json)\n  AS r(id text, company_id text, type text, occurred_at timestamptz, logged_by text, summary text)\nON CONFLICT (id) DO UPDATE\nSET summary = EXCLUDED.summary, occurred_at = EXCLUDED.occurred_at, synced_at = now();",
        "options": {
          "queryReplacement": "={{ [JSON.stringify($json.rows)] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "pgBriefDb",
          "name": "Customer layer (Postgres)"
        }
      }
    },
    {
      "id": "2053aa66-3355-53fd-aef6-32cb52ff3da6",
      "name": "Read me",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -380
      ],
      "parameters": {
        "content": "## Example workflow, not production-ready\n\nFrom the Seamline use case \"The pre-call brief\". It was tested against **simplified stand-ins** for the Stripe, Zendesk, HubSpot, Google Calendar and Slack APIs, at `http://127.0.0.1:8787`.\n\nBefore using it with real systems:\n1. Replace each HTTP Request node with n8n's built-in node for that tool, or point it at the real API and add credentials.\n2. Create the Postgres tables and the `pre_call_brief` function from the article, and set up the **Customer layer (Postgres)** credential.\n3. Replace the manual trigger with a Schedule trigger.\n4. Check field names: contract value, renewal date and notice period are custom HubSpot properties.\n\nAll sample data is fictional.",
        "width": 520,
        "height": 300
      }
    }
  ],
  "connections": {
    "Every 30 minutes (manual for the demo)": {
      "main": [
        [
          {
            "node": "Stripe: subscriptions",
            "type": "main",
            "index": 0
          },
          {
            "node": "Stripe: open invoices",
            "type": "main",
            "index": 0
          },
          {
            "node": "Zendesk: unsolved tickets",
            "type": "main",
            "index": 0
          },
          {
            "node": "HubSpot: companies",
            "type": "main",
            "index": 0
          },
          {
            "node": "HubSpot: notes and calls",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stripe: subscriptions": {
      "main": [
        [
          {
            "node": "Subscriptions to rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Subscriptions to rows": {
      "main": [
        [
          {
            "node": "Save subscriptions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stripe: open invoices": {
      "main": [
        [
          {
            "node": "Invoices to rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Invoices to rows": {
      "main": [
        [
          {
            "node": "Save invoices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Zendesk: unsolved tickets": {
      "main": [
        [
          {
            "node": "Tickets to rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tickets to rows": {
      "main": [
        [
          {
            "node": "Save tickets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HubSpot: companies": {
      "main": [
        [
          {
            "node": "Companies to rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Companies to rows": {
      "main": [
        [
          {
            "node": "Save companies",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HubSpot: notes and calls": {
      "main": [
        [
          {
            "node": "Notes to rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notes to rows": {
      "main": [
        [
          {
            "node": "Save notes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false,
  "pinData": {},
  "id": "briefDemo1"
}
