AutomationFlowsData & Sheets › Venuepro - Create Recurring Booking (fixed)

Venuepro - Create Recurring Booking (fixed)

VenuePro - Create Recurring Booking (Fixed). Uses postgres. Webhook trigger; 16 nodes.

Webhook trigger★★★★☆ complexity16 nodesPostgres
Data & Sheets Trigger: Webhook Nodes: 16 Complexity: ★★★★☆ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "bkD0FkR1MtXvcm59",
  "name": "VenuePro - Create Recurring Booking (Fixed)",
  "active": true,
  "isArchived": false,
  "versionId": "12189e51-634c-43dd-86d2-6191d82c9e2a",
  "activeVersionId": "12189e51-634c-43dd-86d2-6191d82c9e2a",
  "triggerCount": 1,
  "createdAt": "2026-04-06T21:31:33.164Z",
  "updatedAt": "2026-04-07T17:41:05.371Z",
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": true
  },
  "connections": {
    "Webhook: Create Recurring Booking": {
      "main": [
        [
          {
            "node": "Debug: Check Input Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Debug: Check Input Data": {
      "main": [
        [
          {
            "node": "DB: Ensure Schema (payment_timing)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DB: Upsert Customer": {
      "main": [
        [
          {
            "node": "DB: Set Contract Type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DB: Set Contract Type": {
      "main": [
        [
          {
            "node": "Code: Generate Dates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Generate Dates": {
      "main": [
        [
          {
            "node": "Code: Validate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Validate": {
      "main": [
        [
          {
            "node": "DB: Check Room Clashes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DB: Insert Rule": {
      "main": [
        [
          {
            "node": "DB: Insert Series",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DB: Insert Bookings": {
      "main": [
        [
          {
            "node": "DB: Insert Payment Schedule",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DB: Insert Payment Schedule": {
      "main": [
        [
          {
            "node": "Respond: Created",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DB: Ensure Schema (payment_timing)": {
      "main": [
        [
          {
            "node": "DB: Upsert Customer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DB: Insert Series": {
      "main": [
        [
          {
            "node": "DB: Insert Bookings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DB: Check Room Clashes": {
      "main": [
        [
          {
            "node": "Code: Filter Dates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Filter Dates": {
      "main": [
        [
          {
            "node": "IF: Room Available?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF: Room Available?": {
      "main": [
        [
          {
            "node": "DB: Insert Rule",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond: Not Available",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "create-recurring-booking",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "a7c520ae-2a92-485e-9658-fc1a6a86169c",
      "name": "Webhook: Create Recurring Booking",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        7792,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// 1. Get raw input\nconst items = $input.all();\nlet rawData = items[0]?.json || {};\nlet finalResult = {};\n\ntry {\n  if (rawData.hasOwnProperty('0')) {\n    const combinedString = Object.values(rawData).join('');\n    finalResult = JSON.parse(combinedString);\n  } else if (rawData.body && typeof rawData.body === 'object') {\n    finalResult = rawData.body;\n  } else if (typeof rawData.body === 'string') {\n    finalResult = JSON.parse(rawData.body);\n  } else {\n    finalResult = rawData;\n  }\n} catch (e) {\n  finalResult = rawData;\n  finalResult._error = \"Parsing failed: \" + e.message;\n}\n\nconst output = {\n  ...finalResult,\n  tenant_id: parseInt(finalResult.tenant_id || 1001),\n  day_of_week: parseInt(finalResult.day_of_week ?? -1),\n  _debug_parsed: true\n};\n\nreturn [{ json: output }];"
      },
      "id": "b69f4ead-be2e-45a2-bfdc-f8642a4d65da",
      "name": "Debug: Check Input Data",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        7984,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH existing AS (\n  SELECT id AS customer_id, full_name, email, phone, false AS is_new\n  FROM bookings.customers\n  WHERE ((lower(email) = lower(NULLIF($2,'')) AND NULLIF($2,'') IS NOT NULL) OR phone = NULLIF($3,''))\n  AND tenant_id = $4\n  ORDER BY created_at ASC\n  LIMIT 1\n),\nupdated AS (\n  UPDATE bookings.customers\n  SET full_name = CASE\n    WHEN lower(email) = lower(NULLIF($2,'')) AND NULLIF($2,'') IS NOT NULL THEN $1\n    ELSE full_name\n  END,\n      email = CASE\n    WHEN NULLIF($2,'') IS NOT NULL THEN NULLIF($2,'')\n    ELSE email\n  END,\n      phone = COALESCE(NULLIF($3,''), phone)\n  WHERE id = (SELECT customer_id FROM existing) AND tenant_id = $4\n  RETURNING id AS customer_id, full_name, email, phone, false AS is_new\n),\ninserted AS (\n  INSERT INTO bookings.customers (tenant_id, full_name, email, phone)\n  SELECT $4, $1, NULLIF($2,''), NULLIF($3,'')\n  WHERE NOT EXISTS (SELECT 1 FROM existing)\n    AND NULLIF($2,'') IS NOT NULL\n  ON CONFLICT DO NOTHING\n  RETURNING id AS customer_id, full_name, email, phone, true AS is_new\n)\nSELECT * FROM updated\nUNION ALL SELECT * FROM inserted\nLIMIT 1;",
        "options": {
          "queryReplacement": "={{ [\n  ($('Debug: Check Input Data').first().json?.customer_name || '').trim(),\n  ($('Debug: Check Input Data').first().json?.customer_email || '').trim().toLowerCase(),\n  ($('Debug: Check Input Data').first().json?.customer_phone || '').trim(),\n  parseInt($('Debug: Check Input Data').first().json?.tenant_id || '0')\n] }}"
        }
      },
      "id": "c5a9693b-1276-448e-b5fb-c4cf94a7bc35",
      "name": "DB: Upsert Customer",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        8192,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE bookings.customers\nSET customer_type = 'contract'\nWHERE id = $1::uuid\n  AND tenant_id = $2;",
        "options": {
          "queryReplacement": "={{ [\n  $('DB: Upsert Customer').first().json.customer_id,\n  $('Debug: Check Input Data').first().json.tenant_id\n] }}"
        }
      },
      "id": "02e23134-810b-4d20-beb9-6370f85214ca",
      "name": "DB: Set Contract Type",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        8384,
        0
      ],
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const inputData = $('Debug: Check Input Data').first().json;\nconst wb = inputData || {};\n\nconsole.log('Received for Date Gen:', wb);\n\nconst specificDates  = (wb.specific_dates || '').trim();\nconst frequency      = wb.frequency || 'weekly';\nconst startDateStr   = wb.start_date || '';\nconst endDateStr     = wb.end_date || '';\nconst dayOfWeek      = parseInt(wb.day_of_week ?? '-1');\nconst ratePerSession = parseFloat(wb.rate_per_session || '0') || 0;\nconst monthlyRate    = parseFloat(wb.monthly_rate || wb.monthly_fee || '0') || 0;\nconst billingFreq    = (wb.billing_frequency || 'monthly').toLowerCase();\nconst paymentTiming  = wb.payment_timing || 'in_advance';\n// Auto-calculate monthly rate from rate_per_session \u00d7 sessions-per-month\n// when frontend doesn't supply an explicit monthly_rate\nconst sessFreq = (wb.frequency || wb.session_frequency || 'weekly').toLowerCase();\nconst sessionsPerMonth = { weekly: 4, fortnightly: 2, monthly: 1 }[sessFreq] || 4;\nconst effectiveMonthlyRate = monthlyRate > 0\n  ? monthlyRate\n  : parseFloat((ratePerSession * sessionsPerMonth).toFixed(2));\n// Derive amount per billing period\nconst periodAmount = parseFloat(wb.period_amount || '0') ||\n                     (billingFreq === 'fortnightly' ? effectiveMonthlyRate / 2 : effectiveMonthlyRate) ||\n                     ratePerSession;\n\nif (!startDateStr && !specificDates) {\n  return [{ json: { error: \"Missing start_date or specific_dates\", date_count: 0, dates: \"\", payment_timing: paymentTiming } }];\n}\n\nlet dates = [];\n\nif (specificDates) {\n  dates = specificDates.split(',').map(d => d.trim()).filter(d => /^\\d{4}-\\d{2}-\\d{2}$/.test(d)).sort();\n} else {\n  // Generate dates from day_of_week + frequency (fallback path for recurring-bookings.html)\n  const startDate = new Date(startDateStr + 'T12:00:00');\n  const maxDate   = endDateStr ? new Date(endDateStr + 'T12:00:00') : new Date(startDate.getFullYear(), startDate.getMonth() + 3, startDate.getDate());\n  const dow       = dayOfWeek >= 0 ? dayOfWeek : startDate.getDay();\n  const daysToAdd = (dow - startDate.getDay() + 7) % 7;\n  const cursor    = new Date(startDate.getTime() + daysToAdd * 86400000);\n  while (cursor <= maxDate && dates.length < 100) {\n    dates.push(cursor.getFullYear()+'-'+String(cursor.getMonth()+1).padStart(2,'0')+'-'+String(cursor.getDate()).padStart(2,'0'));\n    if (frequency === 'monthly') { cursor.setMonth(cursor.getMonth() + 1); }\n    else if (frequency === 'fortnightly') { cursor.setDate(cursor.getDate() + 14); }\n    else { cursor.setDate(cursor.getDate() + 7); }\n  }\n}\n\nlet monthlyPeriods, monthlyEnds, monthlyAmounts, monthlySessions,\n    firstPeriodStart, firstPeriodEnd, firstMonthSessions, firstMonthAmount;\n\nif (paymentTiming === 'per_session') {\n  // One schedule row per individual session\n  monthlyPeriods  = dates.slice();\n  monthlyEnds     = dates.slice();\n  monthlyAmounts  = dates.map(() => ratePerSession.toFixed(2));\n  monthlySessions = dates.map(() => 1);\n  firstPeriodStart   = dates[0] || '';\n  firstPeriodEnd     = dates[0] || '';\n  firstMonthSessions = dates.length > 0 ? 1 : 0;\n  firstMonthAmount   = ratePerSession.toFixed(2);\n\n} else if (billingFreq === 'fortnightly') {\n  // Group into 14-day windows anchored at the first session date\n  const anchor = new Date(dates[0] + 'T12:00:00');\n  const periodMap = {};\n  for (const d of dates) {\n    const dt = new Date(d + 'T12:00:00');\n    const diffDays = Math.round((dt - anchor) / 86400000);\n    const winIdx   = Math.floor(diffDays / 14);\n    const winStart = new Date(anchor.getTime() + winIdx * 14 * 86400000);\n    const winEnd   = new Date(winStart.getTime() + 13 * 86400000);\n    const ks = winStart.getFullYear()+'-'+String(winStart.getMonth()+1).padStart(2,'0')+'-'+String(winStart.getDate()).padStart(2,'0');\n    const ke = winEnd.getFullYear()  +'-'+String(winEnd.getMonth()+1  ).padStart(2,'0')+'-'+String(winEnd.getDate()  ).padStart(2,'0');\n    if (!periodMap[ks]) periodMap[ks] = { count: 0, period_start: ks, period_end: ke };\n    periodMap[ks].count++;\n  }\n  const sortedKeys = Object.keys(periodMap).sort();\n  monthlyPeriods  = sortedKeys.map(k => periodMap[k].period_start);\n  monthlyEnds     = sortedKeys.map(k => periodMap[k].period_end);\n  monthlyAmounts  = sortedKeys.map(() => periodAmount.toFixed(2));\n  monthlySessions = sortedKeys.map(k => periodMap[k].count);\n  const fk = sortedKeys[0];\n  const fp = fk ? periodMap[fk] : null;\n  firstPeriodStart   = fp ? fp.period_start : '';\n  firstPeriodEnd     = fp ? fp.period_end   : '';\n  firstMonthSessions = fp ? fp.count : 0;\n  firstMonthAmount   = periodAmount.toFixed(2);\n\n} else {\n  // Monthly billing: group by calendar month \u2014 fixed subscription amount per month\n  const monthMap = {};\n  for (const d of dates) {\n    const dt = new Date(d + 'T12:00:00');\n    const y  = dt.getFullYear();\n    const m  = dt.getMonth();\n    const key = y + '-' + String(m + 1).padStart(2, '0');\n    if (!monthMap[key]) {\n      const mEnd = new Date(y, m + 1, 0);\n      monthMap[key] = {\n        count: 0,\n        period_start: key + '-01',\n        period_end:   key + '-' + String(mEnd.getDate()).padStart(2, '0')\n      };\n    }\n    monthMap[key].count++;\n  }\n  const sortedKeys = Object.keys(monthMap).sort();\n  monthlyPeriods  = sortedKeys.map(k => monthMap[k].period_start);\n  monthlyEnds     = sortedKeys.map(k => monthMap[k].period_end);\n  monthlyAmounts  = sortedKeys.map(() => periodAmount.toFixed(2));  // fixed per period\n  monthlySessions = sortedKeys.map(k => monthMap[k].count);\n  const fk = sortedKeys[0];\n  const fm = fk ? monthMap[fk] : null;\n  firstPeriodStart   = fm ? fm.period_start : '';\n  firstPeriodEnd     = fm ? fm.period_end   : '';\n  firstMonthSessions = fm ? fm.count : 0;\n  firstMonthAmount   = periodAmount.toFixed(2);\n}\n\nreturn [{ json: {\n  dates: dates.join(','),\n  date_count: dates.length,\n  payment_timing: paymentTiming,\n  billing_frequency: billingFreq,\n  monthly_rate: monthlyRate.toFixed(2),\n  period_amount: periodAmount.toFixed(2),\n  monthly_periods_csv:  monthlyPeriods.join(','),\n  monthly_ends_csv:     monthlyEnds.join(','),\n  monthly_amounts_csv:  monthlyAmounts.join(','),\n  monthly_sessions_csv: monthlySessions.join(','),\n  monthly_count:  monthlyPeriods.length,\n  period_start:   firstPeriodStart,\n  period_end:     firstPeriodEnd,\n  first_month_sessions: firstMonthSessions,\n  first_month_amount:   firstMonthAmount\n} }];"
      },
      "id": "f04cc50e-145e-416c-b918-75cf9a2c4913",
      "name": "Code: Generate Dates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        8400,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const wb = $('Debug: Check Input Data').first().json;\nconst customerRow = $('DB: Upsert Customer').first().json;\nconst dateGen = $('Code: Generate Dates').first().json;\n\nconst customer_id = customerRow?.customer_id;\nconst room_id = wb?.room_id;\nconst tenant_id = wb?.tenant_id;\n\nconsole.log(\"Validation Check:\", { customer_id, room_id, tenant_id, date_count: dateGen.date_count });\n\nif (!customer_id) {\n  throw new Error(\"Validation Error: The database did not return a customer_id. Check 'DB: Upsert Customer' output.\");\n}\n\nif (!room_id || !tenant_id) {\n  throw new Error(`Validation Error: Missing Webhook data. Room: ${room_id}, Tenant: ${tenant_id}`);\n}\n\nif (!dateGen.dates || dateGen.date_count === 0) {\n  throw new Error(\"Validation Error: No dates were generated for this booking.\");\n}\n\nreturn [{\n  json: {\n    customer_id,\n    room_id,\n    tenant_id,\n    dates: dateGen.dates,\n    date_count: dateGen.date_count,\n    first_month_amount: dateGen.first_month_amount,\n    customer_name: customerRow.full_name || wb.customer_name,\n    customer_email: customerRow.email || wb.customer_email\n  }\n}];"
      },
      "id": "e263f393-db28-4921-94ff-312aa055af1a",
      "name": "Code: Validate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        8592,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO bookings.recurring_rules\n  (tenant_id, customer_id, room_id, day_of_week, start_time, end_time,\n   rate_per_session, active, frequency, end_date,\n   billing_day, total_months, upfront_paid,\n   series_reference, monthly_fee, payment_timing, billing_frequency)\nVALUES\n  ($1, $2::uuid, $3::uuid, $4, $5::time, $6::time,\n   NULLIF($7,'')::numeric, TRUE, $8, NULLIF($9,'')::date,\n   NULLIF($10,'')::int, NULLIF($11,'')::int, $12::boolean,\n   'RB-' || nextval('bookings.series_reference_seq')::text,\n   NULLIF($13,'')::numeric, COALESCE(NULLIF($14,''), 'in_advance'),\n   COALESCE(NULLIF($15,''), 'monthly'))\nRETURNING id AS rule_id, series_reference;",
        "options": {
          "queryReplacement": "={{ [\n  $('Code: Validate').first().json.tenant_id,\n  $('Code: Validate').first().json.customer_id,\n  $('Code: Validate').first().json.room_id,\n  parseInt($('Debug: Check Input Data').first().json?.day_of_week ?? '1'),\n  $('Debug: Check Input Data').first().json?.start_time || '',\n  $('Debug: Check Input Data').first().json?.end_time || '',\n  String($('Debug: Check Input Data').first().json?.rate_per_session || ''),\n  $('Debug: Check Input Data').first().json?.frequency || 'weekly',\n  $('Debug: Check Input Data').first().json?.end_date || '',\n  String($('Debug: Check Input Data').first().json?.billing_day || ''),\n  String($('Debug: Check Input Data').first().json?.total_months || ''),\n  String($('Debug: Check Input Data').first().json?.upfront_paid === true || $('Debug: Check Input Data').first().json?.upfront_paid === 'true' ? 'true' : 'false'),\n  String($('Debug: Check Input Data').first().json?.monthly_rate || $('Debug: Check Input Data').first().json?.monthly_fee || ''),\n  String($('Debug: Check Input Data').first().json?.payment_timing || 'in_advance'),\n  String($('Debug: Check Input Data').first().json?.billing_frequency || 'monthly')\n] }}"
        }
      },
      "id": "23ee12c9-c776-4852-89cc-62d4213a993c",
      "name": "DB: Insert Rule",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        8800,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO bookings.confirmed_bookings\n  (tenant_id, customer_id, room_id, booking_date, date_from, date_to,\n   start_time, end_time, total_amount, deposit_paid, balance_due,\n   status, is_recurring, recurring_rule_id, recurring_series_id, series_label, updated_at)\nSELECT\n  $1, $2::uuid, $3::uuid, d::date, d::date, d::date,\n  $5::time, $6::time,\n  COALESCE(NULLIF($7,'')::numeric, 0),\n  0,\n  0,\n  'confirmed', TRUE,\n  NULLIF($8,'')::uuid,\n  NULLIF($9,'')::uuid,\n  $10, NOW()\nFROM unnest(string_to_array($4, ',')) AS t(d)\nWHERE $4 <> ''\n  -- Guard 1: no overlapping confirmed booking (strict interval overlap)\n  AND NOT EXISTS (\n    SELECT 1 FROM bookings.confirmed_bookings ex\n    WHERE ex.room_id    = $3::uuid\n      AND ex.tenant_id  = $1::integer\n      AND ex.status NOT IN ('cancelled')\n      AND ex.booking_date = d::date\n      AND ex.start_time  < $6::time\n      AND ex.end_time    > $5::time\n  )\n  -- Guard 2: no conflicting active recurring rule at insert time\n  -- Excludes our own just-created rule ($8) so it does not self-block\n  AND NOT EXISTS (\n    SELECT 1 FROM bookings.recurring_rules rr\n    WHERE rr.room_id    = $3::uuid\n      AND rr.tenant_id  = $1::integer\n      AND rr.active     = TRUE\n      AND rr.id        != NULLIF($8,'')::uuid\n      AND rr.day_of_week = EXTRACT(DOW FROM d::date)::int\n      AND rr.start_time  < $6::time\n      AND rr.end_time    > $5::time\n      AND (rr.end_date IS NULL OR rr.end_date >= d::date)\n  )\nRETURNING booking_date::text, id;",
        "options": {
          "queryReplacement": "={{ [\n  $('Code: Validate').first().json.tenant_id,\n  $('Code: Validate').first().json.customer_id,\n  $('Code: Validate').first().json.room_id,\n  $('Code: Filter Dates').first().json.filtered_dates || '',\n  $('Debug: Check Input Data').first().json?.start_time || '',\n  $('Debug: Check Input Data').first().json?.end_time || '',\n  String($('Debug: Check Input Data').first().json?.rate_per_session || ''),\n  $('DB: Insert Rule').first().json.rule_id || '',\n  $('DB: Insert Series').first().json.series_id || '',\n  'Recurring'\n] }}"
        }
      },
      "id": "8b3351c1-56a1-41e9-9ef4-1a273af38e6c",
      "name": "DB: Insert Bookings",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        9104,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO bookings.recurring_payment_schedule (\n    tenant_id,\n    recurring_rule_id,\n    customer_id,\n    period_start,\n    period_end,\n    amount_due,\n    due_date,\n    status,\n    total_cycles,\n    remaining_cycles,\n    billing_day,\n    upfront_paid,\n    payment_timing\n)\nSELECT\n    $1::int,\n    $2::uuid,\n    $3::uuid,\n    ps::date,\n    pe::date,\n    amt::numeric,\n    CASE\n        WHEN $7 = 'in_arrears' THEN (pe::date + INTERVAL '7 days')::date\n        ELSE ps::date\n    END,\n    'pending',\n    NULLIF($8,'')::int,\n    CASE WHEN NULLIF($8,'') IS NOT NULL THEN GREATEST(0, NULLIF($8,'')::int - rn::int) ELSE NULL END,\n    NULLIF($9,'')::int,\n    $10::boolean,\n    $7\nFROM unnest(\n    string_to_array($4, ','),\n    string_to_array($5, ','),\n    string_to_array($6, ',')\n) WITH ORDINALITY AS t(ps, pe, amt, rn)\nON CONFLICT (recurring_rule_id, period_start) DO NOTHING\nRETURNING id, period_start::text, amount_due, due_date::text;",
        "options": {
          "queryReplacement": "={{ [\n  $('Code: Validate').first().json.tenant_id,\n  $('DB: Insert Rule').first().json.rule_id || '',\n  $('Code: Validate').first().json.customer_id,\n  $('Code: Generate Dates').first().json.monthly_periods_csv || '',\n  $('Code: Generate Dates').first().json.monthly_ends_csv || '',\n  $('Code: Generate Dates').first().json.monthly_amounts_csv || '0',\n  $('Code: Generate Dates').first().json.payment_timing || 'in_advance',\n  String($('Debug: Check Input Data').first().json?.total_months || ''),\n  String($('Debug: Check Input Data').first().json?.billing_day || ''),\n  String($('Debug: Check Input Data').first().json?.upfront_paid === true || $('Debug: Check Input Data').first().json?.upfront_paid === 'true' ? 'true' : 'false')\n] }}"
        }
      },
      "id": "be28d34d-6e7f-4757-9f28-06e2761810df",
      "name": "DB: Insert Payment Schedule",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        9312,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({\n  status: 'created',\n  rule_id: $('DB: Insert Rule').first().json.rule_id,\n  series_id: $('DB: Insert Series').first().json.series_id || null,\n  series_reference: $('DB: Insert Rule').first().json.series_reference || null,\n  customer_id: $('Code: Validate').first().json.customer_id,\n  customer_name: $('Code: Validate').first().json.customer_name,\n  booking_count: $('Code: Filter Dates').first().json.dates_count,\n  agreed_price: $('DB: Insert Series').first().json.agreed_price || null,\n  amount_due: $('Code: Generate Dates').first().json.first_month_amount,\n  first_month_sessions: $('Code: Generate Dates').first().json.first_month_sessions || null,\n  frequency: $('Debug: Check Input Data').first().json.frequency || null,\n  total_cycles: $('DB: Insert Payment Schedule').first().json.total_cycles || null,\n  remaining_cycles: $('DB: Insert Payment Schedule').first().json.remaining_cycles || null,\n  billing_day: $('DB: Insert Payment Schedule').first().json.billing_day || null,\n  due_date: $('DB: Insert Payment Schedule').first().json.due_date || null\n,\n  partial_booking: $('Code: Filter Dates').first().json.has_conflict || false,\n  warning: $('Code: Filter Dates').first().json.warning || null,\n  first_conflict_date: $('Code: Filter Dates').first().json.first_conflict_date || null\n}) }}",
        "options": {
          "responseCode": 201,
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "id": "079e1e48-7e75-4c59-a8c1-05ab844ea0a7",
      "name": "Respond: Created",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        9520,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "ALTER TABLE IF EXISTS bookings.recurring_rules ADD COLUMN IF NOT EXISTS payment_timing VARCHAR(20) DEFAULT 'in_advance';\nALTER TABLE IF EXISTS bookings.recurring_payment_schedule ADD COLUMN IF NOT EXISTS payment_timing VARCHAR(20) DEFAULT 'in_advance';\nALTER TABLE IF EXISTS bookings.recurring_rules ADD COLUMN IF NOT EXISTS billing_frequency TEXT DEFAULT 'monthly';\nALTER TABLE IF EXISTS bookings.confirmed_bookings ADD COLUMN IF NOT EXISTS recurring_series_id UUID REFERENCES bookings.recurring_series(id) ON DELETE SET NULL;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS rate_per_session   NUMERIC(10,2) NOT NULL DEFAULT 0;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS total_sessions      INT           NOT NULL DEFAULT 0;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS sessions_completed  INT           NOT NULL DEFAULT 0;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS agreed_price        NUMERIC(10,2) NOT NULL DEFAULT 0;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS status              TEXT          NOT NULL DEFAULT 'active';\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS billing_type        TEXT          NOT NULL DEFAULT 'monthly';\n-- Install the trigger that enforces child balance_due = 0\nCREATE OR REPLACE FUNCTION bookings.fn_zero_child_balance()\nRETURNS TRIGGER LANGUAGE plpgsql AS $fn$\nBEGIN\n    IF NEW.recurring_series_id IS NOT NULL THEN\n        NEW.balance_due := 0;\n    END IF;\n    RETURN NEW;\nEND;\n$fn$;\n\nDROP TRIGGER IF EXISTS trg_zero_child_balance ON bookings.confirmed_bookings;\nCREATE TRIGGER trg_zero_child_balance\n    BEFORE INSERT OR UPDATE OF recurring_series_id, balance_due\n    ON bookings.confirmed_bookings\n    FOR EACH ROW\n    EXECUTE FUNCTION bookings.fn_zero_child_balance();",
        "options": {}
      },
      "id": "2aeb509b-f2eb-457a-ab12-5097d703a9cf",
      "name": "DB: Ensure Schema (payment_timing)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        7984,
        128
      ],
      "continueOnFail": true
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO bookings.recurring_series\n  (tenant_id, customer_id, room_id, series_name, frequency, start_date, end_date,\n   start_time, end_time, rate_per_session, total_sessions,\n   agreed_price, cycle_amount, balance_due,\n   billing_type, payment_timing, status, active)\nVALUES\n  ($1, $2::uuid, $3::uuid, $4, $5,\n   NULLIF($6,'')::date, NULLIF($7,'')::date,\n   NULLIF($8,'')::time, NULLIF($9,'')::time,\n   COALESCE(NULLIF($10,'')::numeric, 0),\n   COALESCE(NULLIF($11,'')::int, 0),\n   ROUND(COALESCE(NULLIF($10,'')::numeric, 0) * COALESCE(NULLIF($11,'')::int, 0), 2),\n   ROUND(COALESCE(NULLIF($10,'')::numeric, 0) * COALESCE(NULLIF($11,'')::int, 0), 2),\n   ROUND(COALESCE(NULLIF($10,'')::numeric, 0) * COALESCE(NULLIF($11,'')::int, 0), 2),\n   COALESCE(NULLIF($12,''), 'monthly'),\n   COALESCE(NULLIF($13,''), 'in_advance'),\n   'active', true)\nRETURNING id AS series_id;",
        "options": {
          "queryReplacement": "={{ [\n  $('Code: Validate').first().json.tenant_id,\n  $('Code: Validate').first().json.customer_id,\n  $('Code: Validate').first().json.room_id,\n  ($('Debug: Check Input Data').first().json?.series_name || $('Debug: Check Input Data').first().json?.customer_name || 'Recurring Session').trim(),\n  $('Debug: Check Input Data').first().json?.frequency || 'weekly',\n  $('Debug: Check Input Data').first().json?.start_date || '',\n  $('Debug: Check Input Data').first().json?.end_date || '',\n  $('Debug: Check Input Data').first().json?.start_time || '',\n  $('Debug: Check Input Data').first().json?.end_time || '',\n  String($('Debug: Check Input Data').first().json?.rate_per_session || '0'),\n  String($('Code: Generate Dates').first().json.date_count || '0'),\n  String($('Debug: Check Input Data').first().json?.billing_frequency || 'monthly'),\n  String($('Debug: Check Input Data').first().json?.payment_timing || 'in_advance')\n] }}"
        }
      },
      "id": "718fd918-c104-4699-8233-5f2bc507e29a",
      "name": "DB: Insert Series",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        8896,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT COALESCE(\n  ARRAY_AGG(d::text ORDER BY d),\n  ARRAY[]::text[]\n) AS clashed_dates\nFROM unnest(string_to_array($1, ',')) AS d\nWHERE $1 <> ''\n  AND (\n    -- Check 1: Existing confirmed bookings (strict interval overlap)\n    EXISTS (\n      SELECT 1 FROM bookings.confirmed_bookings cb\n      WHERE cb.room_id    = NULLIF($2,'')::uuid\n        AND cb.tenant_id  = $3::integer\n        AND cb.status NOT IN ('cancelled')\n        AND d::date BETWEEN COALESCE(cb.date_from::date, cb.booking_date)\n                        AND COALESCE(cb.date_to::date, cb.booking_date)\n        AND cb.start_time < $5::time\n        AND cb.end_time   > $4::time\n    )\n    -- Check 2: Active recurring rules \u2014 catches ungenerated future sessions\n    OR EXISTS (\n      SELECT 1 FROM bookings.recurring_rules rr\n      WHERE rr.room_id    = NULLIF($2,'')::uuid\n        AND rr.tenant_id  = $3::integer\n        AND rr.active     = TRUE\n        AND rr.day_of_week = EXTRACT(DOW FROM d::date)::int\n        AND rr.start_time < $5::time\n        AND rr.end_time   > $4::time\n        AND (rr.end_date IS NULL OR rr.end_date >= d::date)\n    )\n    -- Check 3: Manually blocked dates\n    OR EXISTS (\n      SELECT 1 FROM bookings.blocked_dates bd\n      WHERE bd.tenant_id = $3::integer\n        AND (\n          (bd.block_type = 'oneoff' AND bd.block_date::date = d::date)\n          OR (bd.block_type IN ('recurring', 'recurring-weekday') AND bd.day_of_week = EXTRACT(DOW FROM d::date)::int)\n          OR (bd.block_type = 'range' AND d::date BETWEEN bd.date_from::date AND bd.date_to::date)\n        )\n    )\n  )",
        "options": {
          "queryReplacement": "={{ [\n  $('Code: Validate').first().json.dates || '',\n  $('Code: Validate').first().json.room_id,\n  $('Code: Validate').first().json.tenant_id,\n  $('Debug: Check Input Data').first().json.start_time || '09:00',\n  $('Debug: Check Input Data').first().json.end_time || '23:59'\n] }}"
        }
      },
      "id": "e627f788-98e8-40c2-af56-f519a4b4820e",
      "name": "DB: Check Room Clashes",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        9184,
        336
      ]
    },
    {
      "parameters": {
        "jsCode": "const validate  = $('Code: Validate').first().json;\nconst allDates  = (validate.dates || '').split(',').filter(Boolean).sort();\nconst clashRow  = $input.first().json;\nconst clashed   = new Set((clashRow.clashed_dates || []).map(d => String(d).trim().slice(0, 10)));\n\nconst fmtDate = d => {\n  const dt = new Date(d + 'T12:00:00');\n  return dt.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });\n};\n\n// \u2500\u2500 No clashes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (clashed.size === 0) {\n  return [{ json: {\n    filtered_dates: validate.dates || '',\n    dates_count:    allDates.length,\n    has_conflict:   false,\n    blocked:        false,\n    warning:        null\n  }}];\n}\n\n// \u2500\u2500 Hard block: first session date is unavailable \u2014 reject the whole request \u2500\u2500\nif (allDates.length > 0 && clashed.has(allDates[0])) {\n  return [{ json: {\n    filtered_dates:      '',\n    dates_count:         0,\n    has_conflict:        true,\n    blocked:             true,\n    first_conflict_date: allDates[0],\n    warning: 'The first session date (' + fmtDate(allDates[0]) + ') is already taken for this room at this time. Please choose a different start date or room.'\n  }}];\n}\n\n// \u2500\u2500 Soft block: later dates clash \u2014 exclude only those specific dates \u2500\u2500\u2500\u2500\u2500\u2500\nconst safeDates    = allDates.filter(d => !clashed.has(d));\nconst removedDates = allDates.filter(d =>  clashed.has(d));\n\n// Edge case: somehow all remaining dates clashed\nif (safeDates.length === 0) {\n  return [{ json: {\n    filtered_dates:      '',\n    dates_count:         0,\n    has_conflict:        true,\n    blocked:             true,\n    first_conflict_date: removedDates[0] || null,\n    removed_dates:       removedDates,\n    warning: 'All ' + allDates.length + ' requested session dates are already taken for this room at this time. Please choose a different time or room.'\n  }}];\n}\n\n// Build a readable list of excluded dates\nconst fmtList = removedDates.map(fmtDate);\nlet removedMsg;\nif (fmtList.length === 1) {\n  removedMsg = fmtList[0];\n} else if (fmtList.length <= 4) {\n  removedMsg = fmtList.slice(0, -1).join(', ') + ' and ' + fmtList[fmtList.length - 1];\n} else {\n  removedMsg = fmtList.slice(0, 3).join(', ') + ', and ' + (fmtList.length - 3) + ' more';\n}\n\nreturn [{ json: {\n  filtered_dates:      safeDates.join(','),\n  dates_count:         safeDates.length,\n  has_conflict:        true,\n  blocked:             false,\n  first_conflict_date: removedDates[0],\n  clashed_count:       clashed.size,\n  removed_dates:       removedDates,\n  warning: safeDates.length + ' session(s) booked. ' + removedDates.length + ' date(s) excluded due to conflicts: ' + removedMsg + '.'\n}}];"
      },
      "id": "507d810b-1bfb-4aca-9c29-7733b88fcbb1",
      "name": "Code: Filter Dates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        9424,
        336
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "avail-cond-rb-001",
              "leftValue": "={{ $('Code: Filter Dates').first().json.blocked }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "notEquals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "0d3324bd-4246-4e9c-93ad-c9e2d61f6337",
      "name": "IF: Room Available?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        9664,
        336
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: false, status: 'unavailable', message: $('Code: Filter Dates').first().json.warning || 'Room not available for the requested dates.' }) }}",
        "options": {
          "responseCode": 409
        }
      },
      "id": "6fb8f69a-3c4c-4a08-b9a0-7bc452c80dcf",
      "name": "Respond: Not Available",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        9904,
        496
      ]
    }
  ],
  "activeVersion": {
    "nodes": [
      {
        "parameters": {
          "httpMethod": "POST",
          "path": "create-recurring-booking",
          "responseMode": "responseNode",
          "options": {}
        },
        "id": "a7c520ae-2a92-485e-9658-fc1a6a86169c",
        "name": "Webhook: Create Recurring Booking",
        "type": "n8n-nodes-base.webhook",
        "typeVersion": 2,
        "position": [
          7792,
          0
        ],
        "webhookId": "eca5d49e-e60f-492e-a368-504b62aa46e9"
      },
      {
        "parameters": {
          "jsCode": "// 1. Get raw input\nconst items = $input.all();\nlet rawData = items[0]?.json || {};\nlet finalResult = {};\n\ntry {\n  if (rawData.hasOwnProperty('0')) {\n    const combinedString = Object.values(rawData).join('');\n    finalResult = JSON.parse(combinedString);\n  } else if (rawData.body && typeof rawData.body === 'object') {\n    finalResult = rawData.body;\n  } else if (typeof rawData.body === 'string') {\n    finalResult = JSON.parse(rawData.body);\n  } else {\n    finalResult = rawData;\n  }\n} catch (e) {\n  finalResult = rawData;\n  finalResult._error = \"Parsing failed: \" + e.message;\n}\n\nconst output = {\n  ...finalResult,\n  tenant_id: parseInt(finalResult.tenant_id || 1001),\n  day_of_week: parseInt(finalResult.day_of_week ?? -1),\n  _debug_parsed: true\n};\n\nreturn [{ json: output }];"
        },
        "id": "b69f4ead-be2e-45a2-bfdc-f8642a4d65da",
        "name": "Debug: Check Input Data",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          7984,
          0
        ]
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "WITH existing AS (\n  SELECT id AS customer_id, full_name, email, phone, false AS is_new\n  FROM bookings.customers\n  WHERE ((lower(email) = lower(NULLIF($2,'')) AND NULLIF($2,'') IS NOT NULL) OR phone = NULLIF($3,''))\n  AND tenant_id = $4\n  ORDER BY created_at ASC\n  LIMIT 1\n),\nupdated AS (\n  UPDATE bookings.customers\n  SET full_name = CASE\n    WHEN lower(email) = lower(NULLIF($2,'')) AND NULLIF($2,'') IS NOT NULL THEN $1\n    ELSE full_name\n  END,\n      email = CASE\n    WHEN NULLIF($2,'') IS NOT NULL THEN NULLIF($2,'')\n    ELSE email\n  END,\n      phone = COALESCE(NULLIF($3,''), phone)\n  WHERE id = (SELECT customer_id FROM existing) AND tenant_id = $4\n  RETURNING id AS customer_id, full_name, email, phone, false AS is_new\n),\ninserted AS (\n  INSERT INTO bookings.customers (tenant_id, full_name, email, phone)\n  SELECT $4, $1, NULLIF($2,''), NULLIF($3,'')\n  WHERE NOT EXISTS (SELECT 1 FROM existing)\n    AND NULLIF($2,'') IS NOT NULL\n  ON CONFLICT DO NOTHING\n  RETURNING id AS customer_id, full_name, email, phone, true AS is_new\n)\nSELECT * FROM updated\nUNION ALL SELECT * FROM inserted\nLIMIT 1;",
          "options": {
            "queryReplacement": "={{ [\n  ($('Debug: Check Input Data').first().json?.customer_name || '').trim(),\n  ($('Debug: Check Input Data').first().json?.customer_email || '').trim().toLowerCase(),\n  ($('Debug: Check Input Data').first().json?.customer_phone || '').trim(),\n  parseInt($('Debug: Check Input Data').first().json?.tenant_id || '0')\n] }}"
          }
        },
        "id": "c5a9693b-1276-448e-b5fb-c4cf94a7bc35",
        "name": "DB: Upsert Customer",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.5,
        "position": [
          8192,
          0
        ]
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "UPDATE bookings.customers\nSET customer_type = 'contract'\nWHERE id = $1::uuid\n  AND tenant_id = $2;",
          "options": {
            "queryReplacement": "={{ [\n  $('DB: Upsert Customer').first().json.customer_id,\n  $('Debug: Check Input Data').first().json.tenant_id\n] }}"
          }
        },
        "id": "02e23134-810b-4d20-beb9-6370f85214ca",
        "name": "DB: Set Contract Type",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.6,
        "position": [
          8384,
          0
        ],
        "continueOnFail": true
      },
      {
        "parameters": {
          "jsCode": "const inputData = $('Debug: Check Input Data').first().json;\nconst wb = inputData || {};\n\nconsole.log('Received for Date Gen:', wb);\n\nconst specificDates  = (wb.specific_dates || '').trim();\nconst frequency      = wb.frequency || 'weekly';\nconst startDateStr   = wb.start_date || '';\nconst endDateStr     = wb.end_date || '';\nconst dayOfWeek      = parseInt(wb.day_of_week ?? '-1');\nconst ratePerSession = parseFloat(wb.rate_per_session || '0') || 0;\nconst monthlyRate    = parseFloat(wb.monthly_rate || wb.monthly_fee || '0') || 0;\nconst billingFreq    = (wb.billing_frequency || 'monthly').toLowerCase();\nconst paymentTiming  = wb.payment_timing || 'in_advance';\n// Auto-calculate monthly rate from rate_per_session \u00d7 sessions-per-month\n// when frontend doesn't supply an explicit monthly_rate\nconst sessFreq = (wb.frequency || wb.session_frequency || 'weekly').toLowerCase();\nconst sessionsPerMonth = { weekly: 4, fortnightly: 2, monthly: 1 }[sessFreq] || 4;\nconst effectiveMonthlyRate = monthlyRate > 0\n  ? monthlyRate\n  : parseFloat((ratePerSession * sessionsPerMonth).toFixed(2));\n// Derive amount per billing period\nconst periodAmount = parseFloat(wb.period_amount || '0') ||\n                     (billingFreq === 'fortnightly' ? effectiveMonthlyRate / 2 : effectiveMonthlyRate) ||\n                     ratePerSession;\n\nif (!startDateStr && !specificDates) {\n  return [{ json: { error: \"Missing start_date or specific_dates\", date_count: 0, dates: \"\", payment_timing: paymentTiming } }];\n}\n\nlet dates = [];\n\nif (specificDates) {\n  dates = specificDates.split(',').map(d => d.trim()).filter(d => /^\\d{4}-\\d{2}-\\d{2}$/.test(d)).sort();\n} else {\n  // Generate dates from day_of_week + frequency (fallback path for recurring-bookings.html)\n  const startDate = new Date(startDateStr + 'T12:00:00');\n  const maxDate   = endDateStr ? new Date(endDateStr + 'T12:00:00') : new Date(startDate.getFullYear(), startDate.getMonth() + 3, startDate.getDate());\n  const dow       = dayOfWeek >= 0 ? dayOfWeek : startDate.getDay();\n  const daysToAdd = (dow - startDate.getDay() + 7) % 7;\n  const cursor    = new Date(startDate.getTime() + daysToAdd * 86400000);\n  while (cursor <= maxDate && dates.length < 100) {\n    dates.push(cursor.getFullYear()+'-'+String(cursor.getMonth()+1).padStart(2,'0')+'-'+String(cursor.getDate()).padStart(2,'0'));\n    if (frequency === 'monthly') { cursor.setMonth(cursor.getMonth() + 1); }\n    else if (frequency === 'fortnightly') { cursor.setDate(cursor.getDate() + 14); }\n    else { cursor.setDate(cursor.getDate() + 7); }\n  }\n}\n\nlet monthlyPeriods, monthlyEnds, monthlyAmounts, monthlySessions,\n    firstPeriodStart, firstPeriodEnd, firstMonthSessions, firstMonthAmount;\n\nif (paymentTiming === 'per_session') {\n  // One schedule row per individual session\n  monthlyPeriods  = dates.slice();\n  monthlyEnds     = dates.slice();\n  monthlyAmounts  = dates.map(() => ratePerSession.toFixed(2));\n  monthlySessions = dates.map(() => 1);\n  firstPeriodStart   = dates[0] || '';\n  firstPeriodEnd     = dates[0] || '';\n  firstMonthSessions = dates.length > 0 ? 1 : 0;\n  firstMonthAmount   = ratePerSession.toFixed(2);\n\n} else if (billingFreq === 'fortnightly') {\n  // Group into 14-day windows anchored at the first session date\n  const anchor = new Date(dates[0] + 'T12:00:00');\n  const periodMap = {};\n  for (const d of dates) {\n    const dt = new Date(d + 'T12:00:00');\n    const diffDays = Math.round((dt - anchor) / 86400000);\n    const winIdx   = Math.floor(diffDays / 14);\n    const winStart = new Date(anchor.getTime() + winIdx * 14 * 86400000);\n    const winEnd   = new Date(winStart.getTime() + 13 * 86400000);\n    const ks = winStart.getFullYear()+'-'+String(winStart.getMonth()+1).padStart(2,'0')+'-'+String(winStart.getDate()).padStart(2,'0');\n    const ke = winEnd.getFullYear()  +'-'+String(winEnd.getMonth()+1  ).padStart(2,'0')+'-'+String(winEnd.getDate()  ).padStart(2,'0');\n    if (!periodMap[ks]) periodMap[ks] = { count: 0, period_start: ks, period_end: ke };\n    periodMap[ks].count++;\n  }\n  const sortedKeys = Object.keys(periodMap).sort();\n  monthlyPeriods  = sortedKeys.map(k => periodMap[k].period_start);\n  monthlyEnds     = sortedKeys.map(k => periodMap[k].period_end);\n  monthlyAmounts  = sortedKeys.map(() => periodAmount.toFixed(2));\n  monthlySessions = sortedKeys.map(k => periodMap[k].count);\n  const fk = sortedKeys[0];\n  const fp = fk ? periodMap[fk] : null;\n  firstPeriodStart   = fp ? fp.period_start : '';\n  firstPeriodEnd     = fp ? fp.period_end   : '';\n  firstMonthSessions = fp ? fp.count : 0;\n  firstMonthAmount   = periodAmount.toFixed(2);\n\n} else {\n  // Monthly billing: group by calendar month \u2014 fixed subscription amount per month\n  const monthMap = {};\n  for (const d of dates) {\n    const dt = new Date(d + 'T12:00:00');\n    const y  = dt.getFullYear();\n    const m  = dt.getMonth();\n    const key = y + '-' + String(m + 1).padStart(2, '0');\n    if (!monthMap[key]) {\n      const mEnd = new Date(y, m + 1, 0);\n      monthMap[key] = {\n        count: 0,\n        period_start: key + '-01',\n        period_end:   key + '-' + String(mEnd.getDate()).padStart(2, '0')\n      };\n    }\n    monthMap[key].count++;\n  }\n  const sortedKeys = Object.keys(monthMap).sort();\n  monthlyPeriods  = sortedKeys.map(k => monthMap[k].period_start);\n  monthlyEnds     = sortedKeys.map(k => monthMap[k].period_end);\n  monthlyAmounts  = sortedKeys.map(() => periodAmount.toFixed(2));  // fixed per period\n  monthlySessions = sortedKeys.map(k => monthMap[k].count);\n  const fk = sortedKeys[0];\n  const fm = fk ? monthMap[fk] : null;\n  firstPeriodStart   = fm ? fm.period_start : '';\n  firstPeriodEnd     = fm ? fm.period_end   : '';\n  firstMonthSessions = fm ? fm.count : 0;\n  firstMonthAmount   = periodAmount.toFixed(2);\n}\n\nreturn [{ json: {\n  dates: dates.join(','),\n  date_count: dates.length,\n  payment_timing: paymentTiming,\n  billing_frequency: billingFreq,\n  monthly_rate: monthlyRate.toFixed(2),\n  period_amount: periodAmount.toFixed(2),\n  monthly_periods_csv:  monthlyPeriods.join(','),\n  monthly_ends_csv:     monthlyEnds.join(','),\n  monthly_amounts_csv:  monthlyAmounts.join(','),\n  monthly_sessions_csv: monthlySessions.join(','),\n  monthly_count:  monthlyPeriods.length,\n  period_start:   firstPeriodStart,\n  period_end:     firstPeriodEnd,\n  first_month_sessions: firstMonthSessions,\n  first_month_amount:   firstMonthAmount\n} }];"
        },
        "id": "f04cc50e-145e-416c-b918-75cf9a2c4913",
        "name": "Code: Generate Dates",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          8400,
          0
        ]
      },
      {
        "parameters": {
          "jsCode": "const wb = $('Debug: Check Input Data').first().json;\nconst customerRow = $('DB: Upsert Customer').first().json;\nconst dateGen = $('Code: Generate Dates').first().json;\n\nconst customer_id = customerRow?.customer_id;\nconst room_id = wb?.room_id;\nconst tenant_id = wb?.tenant_id;\n\nconsole.log(\"Validation Check:\", { customer_id, room_id, tenant_id, date_count: dateGen.date_count });\n\nif (!customer_id) {\n  throw new Error(\"Validation Error: The database did not return a customer_id. Check 'DB: Upsert Customer' output.\");\n}\n\nif (!room_id || !tenant_id) {\n  throw new Error(`Validation Error: Missing Webhook data. Room: ${room_id}, Tenant: ${tenant_id}`);\n}\n\nif (!dateGen.dates || dateGen.date_count === 0) {\n  throw new Error(\"Validation Error: No dates were generated for this booking.\");\n}\n\nreturn [{\n  json: {\n    customer_id,\n    room_id,\n    tenant_id,\n    dates: dateGen.dates,\n    date_count: dateGen.date_count,\n    first_month_amount: dateGen.first_month_amount,\n    customer_name: customerRow.full_name || wb.customer_name,\n    customer_email: customerRow.email || wb.customer_email\n  }\n}];"
        },
        "id": "e263f393-db28-4921-94ff-312aa055af1a",
        "name": "Code: Validate",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          8592,
          0
        ]
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "INSERT INTO bookings.recurring_rules\n  (tenant_id, customer_id, room_id, day_of_week, start_time, end_time,\n   rate_per_session, active, frequency, end_date,\n   billing_day, total_months, upfront_paid,\n   series_reference, monthly_fee, payment_timing, billing_frequency)\nVALUES\n  ($1, $2::uuid, $3::uuid, $4, $5::time, $6::time,\n   NULLIF($7,'')::numeric, TRUE, $8, NULLIF($9,'')::date,\n   NULLIF($10,'')::int, NULLIF($11,'')::int, $12::boolean,\n   'RB-' || nextval('bookings.series_reference_seq')::text,\n   NULLIF($13,'')::numeric, COALESCE(NULLIF($14,''), 'in_advance'),\n   COALESCE(NULLIF($15,''), 'monthly'))\nRETURNING id AS rule_id, series_reference;",
          "options": {
            "queryReplacement": "={{ [\n  $('Code: Validate').first().json.tenant_id,\n  $('Code: Validate').first().json.customer_id,\n  $('Code: Validate').first().json.room_id,\n  parseInt($('Debug: Check Input Data').first().json?.day_of_week ?? '1'),\n  $('Debug: Check Input Data').first().json?.start_time || '',\n  $('Debug: Check Input Data').first().json?.end_time || '',\n  String($('Debug: Check Input Data').first().json?.rate_per_session || ''),\n  $('Debug: Check Input Data').first().json?.frequency || 'weekly',\n  $('Debug: Check Input Data').first().json?.end_date || '',\n  String($('Debug: Check Input Data').first().json?.billing_day || ''),\n  String($('Debug: Check Input Data').first().json?.total_months || ''),\n  String($('Debug: Check Input Data').first().json?.upfront_paid === true || $('Debug: Check Input Data').first().json?.upfront_paid === 'true' ? 'true' : 'false'),\n  String($('Debug: Check Input Data').first().json?.monthly_rate || $('Debug: Check Input Data').first().json?.monthly_fee || ''),\n  String($('Debug: Check Input Data').first().json?.payment_timing || 'in_advance'),\n  String($('Debug: Check Input Data').first().json?.billing_frequency || 'monthly')\n] }}"
          }
        },
        "id": "23ee12c9-c776-4852-89cc-62d4213a993c",
        "name": "DB: Insert Rule",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.6,
        "position": [
          8800,
          0
        ]
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "INSERT INTO bookings.confirmed_bookings\n  (tenant_id, customer_id, room_id, booking_date, date_from, date_to,\n   start_time, end_time, total_amount, deposit_paid, balance_due,\n   status, is_recurring, recurring_rule_id, recurring_series_id, series_label, updated_at)\nSELECT\n  $1, $2::uuid, $3::uuid, d::date, d::date, d::date,\n  $5::time, $6::time,\n  COALESCE(NULLIF($7,'')::numeric, 0),\n  0,\n  0,\n  'confirmed', TRUE,\n  NULLIF($8,'')::uuid,\n  NULLIF($9,'')::uuid,\n  $10, NOW()\nFROM unnest(string_to_array($4, ',')) AS t(d)\nWHERE $4 <> ''\n  -- Guard 1: no overlapping confirmed booking (strict interval overlap)\n  AND NOT EXISTS (\n    SELECT 1 FROM bookings.confirmed_bookings ex\n    WHERE ex.room_id    = $3::uuid\n      AND ex.tenant_id  = $1::integer\n      AND ex.status NOT IN ('cancelled')\n      AND ex.booking_date = d::date\n      AND ex.start_time  < $6::time\n      AND ex.end_time    > $5::time\n  )\n  -- Guard 2: no conflicting active recurring rule at insert time\n  -- Excludes our own just-created rule ($8) so it does not self-block\n  AND NOT EXISTS (\n    SELECT 1 FROM bookings.recurring_rules rr\n    WHERE rr.room_id    = $3::uuid\n      AND rr.tenant_id  = $1::integer\n      AND rr.active     = TRUE\n      AND rr.id        != NULLIF($8,'')::uuid\n      AND rr.day_of_week = EXTRACT(DOW FROM d::date)::int\n      AND rr.start_time  < $6::time\n      AND rr.end_time    > $5::time\n      AND (rr.end_date IS NULL OR rr.end_date >= d::date)\n  )\nRETURNING booking_date::text, id;",
          "options": {
            "queryReplacement": "={{ [\n  $('Code: Validate').first().json.tenant_id,\n  $('Code: Validate').first().json.customer_id,\n  $('Code: Validate').first().json.room_id,\n  $('Code: Filter Dates').first().json.filtered_dates || '',\n  $('Debug: Check Input Data').first().json?.start_time || '',\n  $('Debug: Check Input Data').first().json?.end_time || '',\n  String($('Debug: Check Input Data').first().json?.rate_per_session || ''),\n  $('DB: Insert Rule').first().json.rule_id || '',\n  $('DB: Insert Series').first().json.series_id || '',\n  'Recurring'\n] }}"
          }
        },
        "id": "8b3351c1-56a1-41e9-9ef4-1a273af38e6c",
        "name": "DB: Insert Bookings",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.6,
        "position": [
          9104,
          0
        ]
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "INSERT INTO bookings.recurring_payment_schedule (\n    tenant_id,\n    recurring_rule_id,\n    customer_id,\n    period_start,\n    period_end,\n    amount_due,\n    due_date,\n    status,\n    total_cycles,\n    remaining_cycles,\n    billing_day,\n    upfront_paid,\n    payment_timing\n)\nSELECT\n    $1::int,\n    $2::uuid,\n    $3::uuid,\n    ps::date,\n    pe::date,\n    amt::numeric,\n    CASE\n        WHEN $7 = 'in_arrears' THEN (pe::date + INTERVAL '7 days')::date\n        ELSE ps::date\n    END,\n    'pending',\n    NULLIF($8,'')::int,\n    CASE WHEN NULLIF($8,'') IS NOT NULL THEN GREATEST(0, NULLIF($8,'')::int - rn::int) ELSE NULL END,\n    NULLIF($9,'')::int,\n    $10::boolean,\n    $7\nFROM unnest(\n    string_to_array($4, ','),\n    string_to_array($5, ','),\n    string_to_array($6, ',')\n) WITH ORDINALITY AS t(ps, pe, amt, rn)\nON CONFLICT (recurring_rule_id, period_start) DO NOTHING\nRETURNING id, period_start::text, amount_due, due_date::text;",
          "options": {
            "queryReplacement": "={{ [\n  $('Code: Validate').first().json.tenant_id,\n  $('DB: Insert Rule').first().json.rule_id || '',\n  $('Code: Validate').first().json.customer_id,\n  $('Code: Generate Dates').first().json.monthly_periods_csv || '',\n  $('Code: Generate Dates').first().json.monthly_ends_csv || '',\n  $('Code: Generate Dates').first().json.monthly_amounts_csv || '0',\n  $('Code: Generate Dates').first().json.payment_timing || 'in_advance',\n  String($('Debug: Check Input Data').first().json?.total_months || ''),\n  String($('Debug: Check Input Data').first().json?.billing_day || ''),\n  String($('Debug: Check Input Data').first().json?.upfront_paid === true || $('Debug: Check Input Data').first().json?.upfront_paid === 'true' ? 'true' : 'false')\n] }}"
          }
        },
        "id": "be28d34d-6e7f-4757-9f28-06e2761810df",
        "name": "DB: Insert Payment Schedule",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.6,
        "position": [
          9312,
          0
        ]
      },
      {
        "parameters": {
          "respondWith": "json",
          "responseBody": "={{ JSON.stringify({\n  status: 'created',\n  rule_id: $('DB: Insert Rule').first().json.rule_id,\n  series_id: $('DB: Insert Series').first().json.series_id || null,\n  series_reference: $('DB: Insert Rule').first().json.series_reference || null,\n  customer_id: $('Code: Validate').first().json.customer_id,\n  customer_name: $('Code: Validate').first().json.customer_name,\n  booking_count: $('Code: Filter Dates').first().json.dates_count,\n  agreed_price: $('DB: Insert Series').first().json.agreed_price || null,\n  amount_due: $('Code: Generate Dates').first().json.first_month_amount,\n  first_month_sessions: $('Code: Generate Dates').first().json.first_month_sessions || null,\n  frequency: $('Debug: Check Input Data').first().json.frequency || null,\n  total_cycles: $('DB: Insert Payment Schedule').first().json.total_cycles || null,\n  remaining_cycles: $('DB: Insert Payment Schedule').first().json.remaining_cycles || null,\n  billing_day: $('DB: Insert Payment Schedule').first().json.billing_day || null,\n  due_date: $('DB: Insert Payment Schedule').first().json.due_date || null\n,\n  partial_booking: $('Code: Filter Dates').first().json.has_conflict || false,\n  warning: $('Code: Filter Dates').first().json.warning || null,\n  first_conflict_date: $('Code: Filter Dates').first().json.first_conflict_date || null\n}) }}",
          "options": {
            "responseCode": 201,
            "responseHeaders": {
              "entries": [
                {
                  "name": "Access-Control-Allow-Origin",
                  "value": "*"
                }
              ]
            }
          }
        },
        "id": "079e1e48-7e75-4c59-a8c1-05ab844ea0a7",
        "name": "Respond: Created",
        "type": "n8n-nodes-base.respondToWebhook",
        "typeVersion": 1.1,
        "position": [
          9520,
          0
        ]
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "ALTER TABLE IF EXISTS bookings.recurring_rules ADD COLUMN IF NOT EXISTS payment_timing VARCHAR(20) DEFAULT 'in_advance';\nALTER TABLE IF EXISTS bookings.recurring_payment_schedule ADD COLUMN IF NOT EXISTS payment_timing VARCHAR(20) DEFAULT 'in_advance';\nALTER TABLE IF EXISTS bookings.recurring_rules ADD COLUMN IF NOT EXISTS billing_frequency TEXT DEFAULT 'monthly';\nALTER TABLE IF EXISTS bookings.confirmed_bookings ADD COLUMN IF NOT EXISTS recurring_series_id UUID REFERENCES bookings.recurring_series(id) ON DELETE SET NULL;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS rate_per_session   NUMERIC(10,2) NOT NULL DEFAULT 0;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS total_sessions      INT           NOT NULL DEFAULT 0;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS sessions_completed  INT           NOT NULL DEFAULT 0;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS agreed_price        NUMERIC(10,2) NOT NULL DEFAULT 0;\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS status              TEXT          NOT NULL DEFAULT 'active';\nALTER TABLE IF EXISTS bookings.recurring_series ADD COLUMN IF NOT EXISTS billing_type        TEXT          NOT NULL DEFAULT 'monthly';\n-- Install the trigger that enforces child balance_due = 0\nCREATE OR REPLACE FUNCTION bookings.fn_zero_child_balance()\nRETURNS TRIGGER LANGUAGE plpgsql AS $fn$\nBEGIN\n    IF NEW.recurring_series_id IS NOT NULL THEN\n        NEW.balance_due := 0;\n    END IF;\n    RETURN NEW;\nEND;\n$fn$;\n\nDROP TRIGGER IF EXISTS trg_zero_child_balance ON bookings.confirmed_bookings;\nCREATE TRIGGER trg_zero_child_balance\n    BEFORE INSERT OR UPDATE OF recurring_series_id, balance_due\n    ON bookings.confirmed_bookings\n    FOR EACH ROW\n    EXECUTE FUNCTION bookings.fn_zero_child_balance();",
          "options": {}
        },
        "id": "2aeb509b-f2eb-457a-ab12-5097d703a9cf",
        "name": "DB: Ensure Schema (payment_timing)",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.6,
        "position": [
          7984,
          128
        ],
        "continueOnFail": true
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "INSERT INTO bookings.recurring_series\n  (tenant_id, customer_id, room_id, series_name, frequency, start_date, end_date,\n   start_time, end_time, rate_per_session, total_sessions,\n   agreed_price, cycle_amount, balance_due,\n   billing_type, payment_timing, status, active)\nVALUES\n  ($1, $2::uuid, $3::uuid, $4, $5,\n   NULLIF($6,'')::date, NULLIF($7,'')::date,\n   NULLIF($8,'')::time, NULLIF($9,'')::time,\n   COALESCE(NULLIF($10,'')::numeric, 0),\n   COALESCE(NULLIF($11,'')::int, 0),\n   ROUND(COALESCE(NULLIF($10,'')::numeric, 0)
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

VenuePro - Create Recurring Booking (Fixed). Uses postgres. Webhook trigger; 16 nodes.

Source: https://github.com/AndyJay72/VenueDesk/blob/main/VenuePro_CreateRecurringBooking_FIXED.json — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Data & Sheets

Scraping. Uses httpRequest, postgres, @apify/n8n-nodes-apify, respondToWebhook. Webhook trigger; 61 nodes.

HTTP Request, Postgres, @Apify/N8N Nodes Apify
Data & Sheets

Workflow B — AI Listing Engine. Uses httpRequest, postgres, errorTrigger. Webhook trigger; 47 nodes.

HTTP Request, Postgres, Error Trigger
Data & Sheets

How it works

Postgres, Email Send
Data & Sheets

LogSentinel Workflow. Uses postgres, emailSend, httpRequest. Webhook trigger; 44 nodes.

Postgres, Email Send, HTTP Request
Data & Sheets

This workflow automates data maturity evaluation to measure how well an organization uses data to create value by capturing assessment data through forms or APIs, processing and scoring responses usin

Email Send, Postgres