{
  "name": "Koutei: weekly invoices from a timesheet",
  "nodes": [
    {
      "parameters": {
        "content": "## Weekly invoices from a timesheet\nEvery Monday at 07:00 this workflow reads last week's rows (Monday to Sunday) from a Google Sheet, makes one invoice per client with the Koutei PDF API, emails it with Gmail and writes the invoice number back to the rows. **Run now** does the same at any time. Each invoice is dated the day it is made, and its notes give the week of the work.\n\n**The sheet:** a tab named `Timesheet`, one row per piece of work, with these columns in row 1 (within columns A to Z): `date`, `client`, `client_email`, `client_address`, `description`, `hours`, `rate`, `invoice_number`, `note`. `hours` is a plain number such as 1.25. `rate` may be blank when **Settings** has a default rate for that client. `client_address` is optional: the client's postal address, printed under the name, in one cell. In Germany an invoice over €250 must show the client's full name and address. Leave `invoice_number` and `note` empty: the workflow fills them in. If either header is missing or misspelt, the write-back adds it at the end of row 1, so a number is never lost.\n\n**Set up (once)**\n1. In Koutei, open Settings and create an API key.\n2. In n8n, create a **Header Auth** credential. Name: `Authorization`. Value: `Bearer ` followed by your key. Select it in **Check the invoice** and **Make the invoice**.\n3. Select your Google credential and your sheet in **Read the timesheet** and **Write back to the sheet**, and your Gmail credential in **Email the invoice**.\n4. Fill in **Settings** with your own details, your `owner_email`, the currency and the default rates.\n5. Set this workflow's time zone (Settings of the workflow, Timezone): the week, the invoice date and the schedule all follow it.\n\nGuide: https://kouteisystems.com/docs/n8n#weekly-timesheet",
        "height": 720,
        "width": 528,
        "color": 4
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d01",
      "name": "Sticky Note: set up",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -64,
        -896
      ]
    },
    {
      "parameters": {
        "content": "## Preview first, then final\n**mode** in Settings decides: `preview` (as the file comes) emails a watermarked PDF to you only, at `owner_email`, uses no credit and writes only a note. `final` emails the real invoice to the client, uses 1 credit per invoice and writes its number into `invoice_number`, so the next run leaves those rows alone.\n\nA re-run the same day is safe: in `final` mode the same client and week send the same invoice and Idempotency-Key, so Koutei returns the same invoice and uses no credit. On another day within 24 hours the invoice date has changed, so Koutei refuses it rather than make a second invoice, and the note says so. Don't press **Run now** while the Monday run is still going.\n\nIf a run fails at **Write back to the sheet**, fill in `invoice_number` by hand from your Gmail Sent folder before you run that week again.\n\nA problem, such as a missing rate or email or a value Koutei refuses, skips that client and writes a `note` on its rows. The other clients still get their invoices. Fix the row, then run the workflow again.\n\nTo invoice an earlier week, put any date of that week in `week_of`, run the workflow, then clear it. To correct an invoice that was already sent, clear its `invoice_number` cells, set `invoice_suffix` (such as -2), run that week again, then clear `invoice_suffix`: the corrected invoice gets a new number. You are responsible for your invoice numbers.",
        "height": 592,
        "width": 512,
        "color": 7
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d02",
      "name": "Sticky Note: preview and final",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        512,
        -768
      ]
    },
    {
      "parameters": {
        "content": "## 1. Start\nEvery Monday at 07:00, or **Run now** at any time. Fill in **Settings** first.",
        "height": 432,
        "width": 384,
        "color": 7
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d18",
      "name": "Sticky Note: start",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -64,
        -128
      ]
    },
    {
      "parameters": {
        "content": "## 2. Last week's rows\nOne invoice per client, from last week's rows (Monday to Sunday) that have no invoice_number yet.",
        "height": 288,
        "width": 624,
        "color": 7
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d19",
      "name": "Sticky Note: last week's rows",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        368,
        -128
      ]
    },
    {
      "parameters": {
        "content": "## 3. Make the invoice with Koutei\nA free check first, then the PDF. In final mode, a re-run the same day returns the same invoice and uses no credit.",
        "height": 288,
        "width": 800,
        "color": 7
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d20",
      "name": "Sticky Note: Koutei",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1040,
        -128
      ]
    },
    {
      "parameters": {
        "content": "## 4. Email it and write back\nPreview: to you only, with a note in the sheet. Final: to the client, and its number goes into invoice_number. If Gmail fails, the rows get a note.",
        "height": 640,
        "width": 720,
        "color": 7
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d21",
      "name": "Sticky Note: email and write back",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1888,
        -128
      ]
    },
    {
      "parameters": {
        "content": "## Not invoiced\nA problem skips that client: a note goes on its rows.\nThe other clients still get their invoices.\nFix the row, then run the workflow again.",
        "height": 304,
        "width": 624,
        "color": 7
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d22",
      "name": "Sticky Note: not invoiced",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        368,
        208
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "weeksInterval": 1,
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 7,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d03",
      "name": "Every Monday at 07:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {},
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d04",
      "name": "Run now",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        160
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "a7000000-0000-4000-8000-000000000001",
              "name": "mode",
              "value": "preview",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000002",
              "name": "owner_email",
              "value": "you@example.com",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000003",
              "name": "seller_name",
              "value": "Example Design Studio",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000004",
              "name": "seller_details",
              "value": "12 Example Street\nCape Town 8001\nhello@example.com",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000005",
              "name": "tax_id",
              "value": "",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000006",
              "name": "currency",
              "value": "ZAR",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000007",
              "name": "default_rates",
              "value": "Example Client Ltd | 850.00\nSample Agency | 1,200.00",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000008",
              "name": "tax_rate",
              "value": "",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000009",
              "name": "tax_label",
              "value": "",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000010",
              "name": "invoice_prefix",
              "value": "INV-",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000016",
              "name": "invoice_suffix",
              "value": "",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000011",
              "name": "due_in_days",
              "value": "14",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000012",
              "name": "payment_terms",
              "value": "Due within 14 days",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000013",
              "name": "payment_instructions",
              "value": "Example Bank, account 000 000 000. Use the invoice number as the reference.",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000014",
              "name": "payment_link",
              "value": "",
              "type": "string"
            },
            {
              "id": "a7000000-0000-4000-8000-000000000015",
              "name": "week_of",
              "value": "",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d05",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        208,
        0
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": ""
        },
        "sheetName": {
          "__rl": true,
          "value": "Timesheet",
          "mode": "name"
        },
        "options": {
          "outputFormatting": {
            "values": {
              "general": "UNFORMATTED_VALUE",
              "date": "SERIAL_NUMBER"
            }
          }
        }
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d06",
      "name": "Read the timesheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        416,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// One invoice per client for last week's rows (Monday to Sunday, in this\n// workflow's time zone). Rows that already have an invoice_number are left\n// alone. A client with a problem is skipped, with a note on its rows; the\n// other clients still get their invoices.\nconst settings = $('Settings').first().json;\nconst DAY = 86400000;\n\nconst blank = (value) => value === undefined || value === null || String(value).trim() === '';\nconst text = (value) => (blank(value) ? '' : String(value).trim().replace(/\\s+/g, ' '));\n// A cell that may hold several lines (an address): each line trimmed, blank lines dropped.\nconst lines = (value) =>\n  blank(value)\n    ? ''\n    : String(value)\n        .replace(/\\r\\n?/g, '\\n')\n        .split('\\n')\n        .map((line) => line.trim().replace(/\\s+/g, ' '))\n        .filter((line) => line !== '')\n        .join('\\n');\nconst isoDate = (ms) => new Date(ms).toISOString().slice(0, 10);\nconst looksLikeEmail = (value) => /^[^\\s@,;<>]+@[^\\s@,;<>]+\\.[^\\s@,;<>]+$/.test(value);\n\nfunction parseIsoDate(value) {\n  const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(value);\n  if (!match) return null;\n  const ms = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));\n  return isoDate(ms) === value ? ms : null;\n}\n\n// A date cell: Google Sheets sends a serial number (days since 1899-12-30); a text cell must be YYYY-MM-DD.\nfunction cellDate(value) {\n  if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? Date.UTC(1899, 11, 30) + Math.floor(value) * DAY : null;\n  return parseIsoDate(text(value));\n}\n\n// A number cell as the API reads it: a JSON number's shortest decimal, or the text as typed.\nconst amountText = (value) => (typeof value === 'number' ? String(value) : text(value));\n// A comma is read only as a thousands separator, so text with a comma must look like 1,200.00:\n// \"1,5\" or \"1,500\" could be a decimal comma, and is never guessed.\nconst unclearComma = (value) => typeof value === 'string' && value.includes(',') && !/^-?\\d{1,3}(,\\d{3})+\\.\\d+$/.test(text(value));\n\n// A short, stable fingerprint (cyrb53): the same text always gives the same 16 hex characters.\nfunction fingerprint(value) {\n  let h1 = 0xdeadbeef;\n  let h2 = 0x41c6ce57;\n  for (let i = 0; i < value.length; i++) {\n    const code = value.charCodeAt(i);\n    h1 = Math.imul(h1 ^ code, 2654435761);\n    h2 = Math.imul(h2 ^ code, 1597334677);\n  }\n  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);\n  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);\n  return (h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0');\n}\n\n// The client part of the invoice number: its name in capitals, at most 20 characters.\nfunction clientCode(name) {\n  const words = name.normalize('NFKD').replace(/[̀-ͯ]/g, '').toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean);\n  let code = '';\n  for (const word of words) {\n    const longer = code === '' ? word : `${code}-${word}`;\n    if (longer.length > 20) break;\n    code = longer;\n  }\n  if (code === '' && words.length > 0) code = words[0].slice(0, 20);\n  return code === '' ? `C${fingerprint(name).slice(0, 6).toUpperCase()}` : code;\n}\n\nconst MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\nconst longDate = (ms) => {\n  const d = new Date(ms);\n  return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;\n};\n\n// --- The sheet: the columns every usable timesheet has a value in somewhere, or nothing is sent. A row may\n// come without the keys of its trailing empty cells (a read from a specified range), so a column counts when\n// any row has it. rate, invoice_number and note may be blank everywhere: they are not checked here.\nconst input = $input.all();\nconst NEEDED = ['date', 'client', 'client_email', 'description', 'hours'];\nconst seen = new Set(input.flatMap((item) => Object.keys(item.json)));\nconst missing = NEEDED.filter((column) => !seen.has(column));\nif (missing.length > 0) {\n  throw new Error(\n    `The sheet needs these columns in row 1: date, client, client_email, description, hours, rate, invoice_number, note (client_address is optional). Missing, or empty in every row: ${missing.join(', ')}`,\n  );\n}\n\n// --- Settings: a mistake here stops the run before anything is sent.\nconst mode = text(settings.mode);\nif (mode !== 'preview' && mode !== 'final') throw new Error('Settings: mode must be preview or final');\nconst ownerEmail = text(settings.owner_email);\nif (mode === 'preview' && !looksLikeEmail(ownerEmail)) throw new Error('Settings: owner_email must be your own email address: previews go only to you');\n\nconst defaultRates = new Map();\nString(settings.default_rates ?? '')\n  .split('\\n')\n  .forEach((line, index) => {\n    if (blank(line)) return;\n    const bar = line.lastIndexOf('|');\n    const client = bar === -1 ? '' : text(line.slice(0, bar));\n    const rate = bar === -1 ? '' : text(line.slice(bar + 1));\n    if (client === '' || rate === '') throw new Error(`Settings: default_rates line ${index + 1} must look like \"Client name | 850.00\"`);\n    defaultRates.set(client, rate);\n  });\n\nlet dueDays = null;\nif (!blank(settings.due_in_days)) {\n  dueDays = Number(text(settings.due_in_days));\n  if (!Number.isInteger(dueDays) || dueDays < 0 || dueDays > 365) throw new Error('Settings: due_in_days must be a whole number of days from 0 to 365, or blank');\n}\n\n// --- The week: last week, Monday to Sunday, or the week that contains week_of.\n// The invoice is dated today (the day it is issued, never earlier); its notes give the week of the work.\nconst today = parseIsoDate($now.toISODate());\nlet anchor;\nif (blank(settings.week_of)) {\n  anchor = today - 7 * DAY;\n} else {\n  anchor = parseIsoDate(text(settings.week_of));\n  if (anchor === null) throw new Error('Settings: week_of must be blank (last week) or a date such as 2026-10-19');\n}\nconst weekStart = anchor - ((new Date(anchor).getUTCDay() + 6) % 7) * DAY;\nconst weekEnd = weekStart + 6 * DAY;\nconst thursday = weekStart + 3 * DAY;\nconst weekYear = new Date(thursday).getUTCFullYear();\nconst weekNumber = Math.floor((thursday - Date.UTC(weekYear, 0, 1)) / DAY / 7) + 1;\nconst weekLabel = `${weekYear}-W${String(weekNumber).padStart(2, '0')}`;\nconst invoiceDate = today;\n\n// --- The rows.\nconst numbersInSheet = new Set();\nfor (const item of input) if (!blank(item.json.invoice_number)) numbersInSheet.add(text(item.json.invoice_number));\n\nconst groups = new Map();\nconst looseNotes = [];\nconst loose = [];\ninput.forEach((item, index) => {\n  const row = item.json;\n  if (!blank(row.invoice_number)) return;\n  const filled = ['date', 'client', 'client_email', 'client_address', 'description', 'hours', 'rate'].some((column) => !blank(row[column]));\n  if (!filled) return;\n  const day = cellDate(row.date);\n  if (day === null) {\n    looseNotes.push({ row_number: row.row_number, note: 'Not invoiced: the date must be a date, such as 2026-10-19.' });\n    loose.push(index);\n    return;\n  }\n  if (day < weekStart || day > weekEnd) return;\n  const client = text(row.client);\n  if (client === '') {\n    looseNotes.push({ row_number: row.row_number, note: 'Not invoiced: the client is empty.' });\n    loose.push(index);\n    return;\n  }\n  if (!groups.has(client)) groups.set(client, []);\n  groups.get(client).push({ index, day, row });\n});\n\n// invoice_suffix (blank, or such as -2) gives a re-issued invoice a new number, and a new Idempotency-Key.\nconst prefix = text(settings.invoice_prefix);\nconst suffix = text(settings.invoice_suffix);\nconst plans = [...groups.keys()].sort().map((client) => ({ client, number: `${prefix}${weekLabel}-${clientCode(client)}${suffix}` }));\nconst clientsByNumber = new Map();\nfor (const plan of plans) clientsByNumber.set(plan.number, (clientsByNumber.get(plan.number) ?? 0) + 1);\n\nconst out = [];\nif (looseNotes.length > 0) out.push({ json: { ready: false, client: '', rows: looseNotes.map((n) => n.row_number), row_notes: looseNotes }, pairedItem: loose.map((i) => ({ item: i })) });\n\nfor (const { client, number } of plans) {\n  const entries = groups.get(client).sort((a, b) => a.day - b.day || a.row.row_number - b.row.row_number);\n  const rowNumbers = entries.map((entry) => entry.row.row_number);\n  const pairedItem = entries.map((entry) => ({ item: entry.index }));\n  const rowProblems = new Map();\n  const items = entries.map(({ day, row }) => {\n    const problems = [];\n    const description = text(row.description);\n    if (description === '') problems.push('add a description');\n    const hours = amountText(row.hours);\n    if (hours === '') problems.push('add the hours');\n    else if (typeof row.hours === 'string' && hours.includes(',')) problems.push('write the hours with a point, such as 1.25');\n    let rate = amountText(row.rate);\n    if (rate !== '' && unclearComma(row.rate)) problems.push('write the rate with a point, such as 1500.00 or 1,500.00');\n    if (rate === '') {\n      rate = defaultRates.get(client) ?? '';\n      if (rate === '') problems.push('add a rate, or a default rate for this client in Settings');\n      else if (unclearComma(rate)) problems.push(\"write this client's default rate in Settings with a point, such as 1500.00 or 1,500.00\");\n    }\n    if (problems.length > 0) rowProblems.set(row.row_number, problems);\n    return { description: `${isoDate(day)} ${description}`, quantity: hours, unit_price: rate };\n  });\n\n  const clientProblems = [];\n  const emails = [...new Set(entries.map((entry) => text(entry.row.client_email)).filter((email) => email !== ''))];\n  if (emails.length === 0) clientProblems.push('add the client_email');\n  else if (emails.length > 1) clientProblems.push(`this client's rows have different client_email values (${emails.join(', ')})`);\n  else if (!looksLikeEmail(emails[0])) clientProblems.push(`${emails[0]} does not look like an email address`);\n  const addresses = [...new Set(entries.map((entry) => lines(entry.row.client_address)).filter((address) => address !== ''))];\n  if (addresses.length > 1) clientProblems.push(\"this client's rows have different client_address values: use one address on every row\");\n  if (numbersInSheet.has(number)) clientProblems.push(`${number} is already in the sheet: invoice these rows by hand, or give them another week`);\n  if (clientsByNumber.get(number) > 1) clientProblems.push(`another client name gives the same invoice number, ${number}: make the names differ`);\n\n  if (rowProblems.size > 0 || clientProblems.length > 0) {\n    const firstRow = rowNumbers.find((n) => rowProblems.has(n));\n    const rowNotes = rowNumbers.map((n) => {\n      const problems = [...clientProblems, ...(rowProblems.get(n) ?? [])];\n      if (problems.length === 0) problems.push(`row ${firstRow} for this client needs attention`);\n      return { row_number: n, note: `Not invoiced: ${problems.join('; ')}.` };\n    });\n    out.push({ json: { ready: false, client, rows: rowNumbers, row_notes: rowNotes }, pairedItem });\n    continue;\n  }\n\n  const fields = {\n    seller_name: text(settings.seller_name),\n    seller_details: String(settings.seller_details ?? '').trim(),\n    tax_id: text(settings.tax_id),\n    client_name: client,\n    client_details: addresses[0] ?? '',\n    invoice_number: number,\n    date: isoDate(invoiceDate),\n    currency: text(settings.currency),\n    items,\n    tax_rate: text(settings.tax_rate),\n    tax_label: text(settings.tax_label),\n    payment_terms: text(settings.payment_terms),\n    payment_instructions: String(settings.payment_instructions ?? '').trim(),\n    payment_link: text(settings.payment_link),\n    notes: `Work from ${longDate(weekStart)} to ${longDate(weekEnd)}.`,\n  };\n  if (dueDays !== null) fields.due_date = isoDate(invoiceDate + dueDays * DAY);\n  const request = { template: 'invoice', mode, reference: `timesheet-${isoDate(weekStart)}`, fields };\n\n  // The same client, week and suffix always give the same key, so a re-run never makes a second final invoice.\n  // A preview's key also follows its rows, so a corrected preview is made again (previews are free).\n  const clientKey = `timesheet-${isoDate(weekStart)}-${fingerprint(client)}${suffix === '' ? '' : `-${fingerprint(suffix)}`}`;\n  const idempotencyKey = mode === 'final' ? clientKey : `${clientKey}-preview-${fingerprint(JSON.stringify(request))}`;\n\n  const sellerName = fields.seller_name;\n  const period = `${longDate(weekStart)} to ${longDate(weekEnd)}`;\n  const email =\n    mode === 'final'\n      ? {\n          email_to: emails[0],\n          email_subject: `Invoice ${number} from ${sellerName}`,\n          email_text: `Hello,\\n\\nPlease find attached invoice ${number} for the work from ${period}.\\n\\nThank you,\\n${sellerName}`,\n        }\n      : {\n          email_to: ownerEmail,\n          email_subject: `Preview: invoice ${number} for ${client}`,\n          email_text:\n            `This is a preview of the invoice ${client} (${emails[0]}) will get. Only you got this email: the PDF is watermarked and used no credit.\\n\\n` +\n            'When the previews look right, set mode to final in the Settings node. The next run sends the real invoices to your clients.',\n        };\n\n  out.push({\n    json: {\n      ready: true,\n      client,\n      client_email: emails[0],\n      mode,\n      week_start: isoDate(weekStart),\n      week_end: isoDate(weekEnd),\n      invoice_number: number,\n      idempotency_key: idempotencyKey,\n      request,\n      ...email,\n      rows: rowNumbers,\n    },\n    pairedItem,\n  });\n}\nreturn out;"
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d07",
      "name": "Group last week",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        624,
        0
      ],
      "notes": "One item per client: last week's rows without an invoice_number, the request, the key and the email.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "c7000000-0000-4000-8000-000000000008",
              "leftValue": "={{ $json.ready }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d08",
      "name": "Ready to invoice?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        832,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://kouteisystems.com/api/v1/documents/validate",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            },
            {
              "name": "Koutei-Version",
              "value": "2026-10-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.request) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          },
          "timeout": 60000
        }
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d09",
      "name": "Check the invoice",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1104,
        0
      ],
      "onError": "continueErrorOutput",
      "notes": "POST /documents/validate: the same checks, no PDF, no credit.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "c7000000-0000-4000-8000-000000000010",
              "leftValue": "={{ $json.statusCode }}",
              "rightValue": 200,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d10",
      "name": "Valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1312,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://kouteisystems.com/api/v1/documents",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/pdf"
            },
            {
              "name": "Koutei-Version",
              "value": "2026-10-01"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $('Group last week').item.json.idempotency_key }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($('Group last week').item.json.request) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "autodetect"
            }
          },
          "timeout": 60000
        }
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d11",
      "name": "Make the invoice",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1520,
        0
      ],
      "onError": "continueErrorOutput",
      "notes": "The PDF arrives as the file \"data\"; a refusal arrives as JSON for the note.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "c7000000-0000-4000-8000-000000000012",
              "leftValue": "={{ $json.statusCode }}",
              "rightValue": 201,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d12",
      "name": "Made?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1728,
        0
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "={{ $('Group last week').item.json.email_to }}",
        "subject": "={{ $('Group last week').item.json.email_subject }}",
        "emailType": "text",
        "message": "={{ $('Group last week').item.json.email_text }}",
        "options": {
          "appendAttribution": false,
          "attachmentsUi": {
            "attachmentsBinary": [
              {
                "property": "data"
              }
            ]
          }
        }
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d13",
      "name": "Email the invoice",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1952,
        0
      ],
      "onError": "continueErrorOutput",
      "notes": "Final: to the client. Preview: to owner_email only.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// The rows of each emailed invoice. A final invoice writes its number back, so\n// those rows are never invoiced again; a preview writes only a note.\nconst today = $now.toISODate();\nconst out = [];\n$input.all().forEach((item, i) => {\n  const group = $('Group last week').itemMatching(i).json;\n  const headers = $('Make the invoice').itemMatching(i).json.headers || {};\n  const total = `${headers['koutei-totals-currency'] || ''} ${headers['koutei-totals-total'] || ''}`.trim();\n  for (const row of group.rows) {\n    const json =\n      group.mode === 'final'\n        ? { row_number: row, invoice_number: group.invoice_number, note: `Invoiced on ${today}: ${group.invoice_number}, ${total}, emailed to ${group.email_to}.` }\n        : {\n            row_number: row,\n            invoice_number: '',\n            note: `Preview only (${today}): ${group.invoice_number}, ${total}, was emailed to you. Set mode to final in Settings to send it to the client.`,\n          };\n    out.push({ json, pairedItem: { item: i } });\n  }\n});\nreturn out;"
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d14",
      "name": "Mark the rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2192,
        0
      ],
      "notes": "Final: the invoice number and a note. Preview: a note only.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// The notes for a client that was not invoiced: its own problems from\n// \"Group last week\", or what Koutei answered. Nothing was emailed, and the\n// rows keep an empty invoice_number, so the next run tries them again.\nconst COLUMN = { description: 'description', quantity: 'hours', unit_price: 'rate' };\nconst PLAIN = {\n  'quantity:not_a_number': 'the hours must be a plain number, such as 1.25',\n  'quantity:too_many_digits': 'the hours may have at most 4 digits after the point',\n  'unit_price:not_a_number': 'the rate must be a plain number, such as 850.00, with no currency sign',\n  'unit_price:too_many_digits': 'the rate may have at most 4 digits after the point',\n  'description:too_long': 'the date and the description must fit in 200 characters',\n  'description:line_break_not_allowed': 'the description must be on one line',\n  'description:invalid_characters': 'the description has a character an invoice cannot print',\n};\nconst HINTS = {\n  unauthorized: 'the key in the Koutei credential does not work: check the credential',\n  forbidden_scope: 'this API key can make previews only: final invoices need a full-access key',\n  insufficient_credits: 'no credits are left this month: add credits, then run the workflow again',\n  idempotency_key_reused:\n    'an invoice for this client and week was made in the last 24 hours, from other rows or on another day, so nothing new was made. Check your Gmail Sent folder: if the client has it, write its number into invoice_number; if not, run the workflow again after 24 hours',\n  idempotency_in_progress: 'the same invoice was still being made: run the workflow again in a minute',\n  rate_limited: 'Koutei asked the workflow to slow down: run it again later',\n};\n\nconst errorMessage = (error) => {\n  if (typeof error === 'string') return error;\n  if (error && typeof error === 'object') return String(error.message || error.description || 'unknown error');\n  return 'unknown error';\n};\n\nfunction answerNotes(group, answer) {\n  const rows = group.rows;\n  const all = (text) => rows.map((n) => ({ row_number: n, note: `Not invoiced: ${text}.` }));\n  // An item from a Koutei node's error output (no answer at all) carries `error`. n8n merges the item that went\n  // into the node into it, so it may also carry the previous node's statusCode: the error is read first.\n  if (answer.error !== undefined || answer.statusCode === undefined) return all(`Koutei could not be reached (${errorMessage(answer.error)}): run the workflow again`);\n  const error = (answer.body && answer.body.error) || {};\n  if (error.code === 'validation_failed' && Array.isArray(error.details)) {\n    const own = new Map();\n    const general = [];\n    let tooManyRows = false;\n    for (const detail of error.details) {\n      if (detail.field === 'fields.items' && detail.code === 'too_many_items') {\n        tooManyRows = true;\n        continue;\n      }\n      const match = /^fields\\.items\\[(\\d+)\\]\\.(description|quantity|unit_price)$/.exec(detail.field || '');\n      const row = match ? rows[Number(match[1])] : undefined;\n      if (row === undefined) {\n        general.push(`${String(detail.field || '').replace(/^fields\\./, '') || 'the invoice'}: ${detail.message}`);\n        continue;\n      }\n      const words = PLAIN[`${match[2]}:${detail.code}`] || `${COLUMN[match[2]]}: ${detail.message}`;\n      own.set(row, [...(own.get(row) || []), words]);\n    }\n    const firstRow = rows.find((n) => own.has(n));\n    return rows.map((n) => {\n      const problems = [\n        ...(tooManyRows ? [`this client has ${rows.length} rows this week and an invoice holds at most 50 lines: combine some rows, or invoice this week by hand`] : []),\n        ...(general.length > 0 ? [`Koutei refused the invoice (check Settings): ${general.join('; ')}`] : []),\n        ...(own.get(n) || []),\n      ];\n      if (problems.length === 0) problems.push(`row ${firstRow} for this client needs attention`);\n      return { row_number: n, note: `Not invoiced: ${problems.join('; ')}.` };\n    });\n  }\n  const code = error.code || 'no_code';\n  return all(`Koutei answered ${answer.statusCode} ${code}: ${HINTS[code] || error.message || 'see the run in n8n'}`);\n}\n\nconst out = [];\n$input.all().forEach((item, i) => {\n  const notes = Array.isArray(item.json.row_notes) ? item.json.row_notes : answerNotes($('Group last week').itemMatching(i).json, item.json);\n  for (const note of notes) out.push({ json: { row_number: note.row_number, invoice_number: '', note: note.note }, pairedItem: { item: i } });\n});\nreturn out;"
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d15",
      "name": "Note the problem",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        832,
        352
      ],
      "notes": "Skipped clients: a note on each row, and the invoice_number stays empty.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// The rows of an invoice Gmail could not send. They keep an empty\n// invoice_number, so the next run tries them again: the same day, Koutei\n// returns the same invoice and uses no credit.\nconst out = [];\n$input.all().forEach((item, i) => {\n  const group = $('Group last week').itemMatching(i).json;\n  const error = item.json.error;\n  const reason = typeof error === 'string' ? error : String((error && (error.message || error.description)) || 'unknown error');\n  const note =\n    group.mode === 'final'\n      ? `Not sent: ${group.invoice_number} was made, but Gmail could not send it (${reason}). Run the workflow again today to send the same invoice at no extra cost.`\n      : `Preview not sent: Gmail could not send it (${reason}).`;\n  for (const row of group.rows) out.push({ json: { row_number: row, invoice_number: '', note }, pairedItem: { item: i } });\n});\nreturn out;"
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d16",
      "name": "Note the email problem",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2192,
        160
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": ""
        },
        "sheetName": {
          "__rl": true,
          "value": "Timesheet",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "row_number"
          ],
          "schema": [
            {
              "id": "invoice_number",
              "displayName": "invoice_number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "note",
              "displayName": "note",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "displayName": "row_number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true,
              "readOnly": true,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "cellFormat": "RAW",
          "handlingExtraData": "insertInNewColumn"
        }
      },
      "id": "5b7d9f1a-3c4e-4a6b-8d2f-7e9a1c3b5d17",
      "name": "Write back to the sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        2448,
        352
      ],
      "notes": "Writes only invoice_number and note, on the rows by row_number.",
      "notesInFlow": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    }
  ],
  "connections": {
    "Every Monday at 07:00": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run now": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Read the timesheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the timesheet": {
      "main": [
        [
          {
            "node": "Group last week",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Group last week": {
      "main": [
        [
          {
            "node": "Ready to invoice?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ready to invoice?": {
      "main": [
        [
          {
            "node": "Check the invoice",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Note the problem",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the invoice": {
      "main": [
        [
          {
            "node": "Valid?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Note the problem",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Valid?": {
      "main": [
        [
          {
            "node": "Make the invoice",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Note the problem",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Make the invoice": {
      "main": [
        [
          {
            "node": "Made?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Note the problem",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Made?": {
      "main": [
        [
          {
            "node": "Email the invoice",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Note the problem",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Email the invoice": {
      "main": [
        [
          {
            "node": "Mark the rows",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Note the email problem",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark the rows": {
      "main": [
        [
          {
            "node": "Write back to the sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Note the problem": {
      "main": [
        [
          {
            "node": "Write back to the sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Note the email problem": {
      "main": [
        [
          {
            "node": "Write back to the sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "active": false
}
