{
  "name": "ChartPNG: weekly Stripe revenue to Slack",
  "nodes": [
    {
      "parameters": {
        "content": "**ChartPNG: weekly Stripe revenue to Slack**\n\n1. In n8n, create a Stripe API credential with your secret key and pick it in \"List paid invoices\". The file holds no key.\n2. In \"Post to Slack\", pick your Slack credential and set the channel.\n3. Run the workflow once, then publish it (Active on n8n 1.x).\n\nThe figure is an approximation: the sum of paid invoices by week, UTC, tax included. It is not MRR: no proration, refunds or churn. The workflow only reads invoices and changes nothing in Stripe.\n\nWhich Monday it is follows the workflow timezone (Workflow settings). The weeks are cut at 00:00 UTC.\n\nThis URL is unsigned, so the image keeps the small watermark. A signed URL (see Docs, Signed URLs) removes it: https://chartpng.dev/docs#signed-urls\n\nDocs: https://chartpng.dev/guides/stripe-mrr-to-slack",
        "height": 460,
        "width": 440
      },
      "id": "stripe-note",
      "name": "Setup notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -180
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "weeksInterval": 1,
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 9
            }
          ]
        }
      },
      "id": "stripe-trigger",
      "name": "Every Monday",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        260
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// The last 12 whole weeks, Monday to Sunday, ending at the start of this Monday. Stripe takes Unix seconds.\n// Which Monday it is comes from n8n's $now (Luxon) in the workflow's timezone (Workflow settings), so the\n// Monday 09:00 run in Sydney or Auckland, which is still Sunday in UTC, picks the Monday that just started.\n// The weeks themselves are cut at 00:00 UTC on those dates. East of UTC that is a few hours after the run,\n// so the last hours of the final UTC Sunday are not in the chart.\nconst WEEKS = 12;\nconst WEEK = 7 * 86400;\nconst d = $now.startOf('day');\nconst m = d.minus({ days: d.weekday - 1 });\nconst monday = Date.UTC(m.year, m.month - 1, m.day) / 1000;\nreturn [{ json: { from: monday - WEEKS * WEEK, to: monday, weeks: WEEKS } }];"
      },
      "id": "stripe-range",
      "name": "Date range",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        260
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://api.stripe.com/v1/invoices",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "stripeApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "status",
              "value": "paid"
            },
            {
              "name": "created[gte]",
              "value": "={{ $json.from }}"
            },
            {
              "name": "created[lt]",
              "value": "={{ $json.to }}"
            },
            {
              "name": "limit",
              "value": "100"
            }
          ]
        },
        "options": {
          "pagination": {
            "pagination": {
              "paginationMode": "updateAParameterInEachRequest",
              "parameters": {
                "parameters": [
                  {
                    "type": "qs",
                    "name": "starting_after",
                    "value": "={{ $response.body.data.at(-1).id }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ !$response.body.has_more }}",
              "limitPagesFetched": true,
              "maxRequests": 20
            }
          }
        }
      },
      "id": "stripe-invoices",
      "name": "List paid invoices",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        480,
        260
      ],
      "credentials": {
        "stripeApi": {
          "id": "REPLACE_WITH_CREDENTIAL_ID",
          "name": "Stripe account"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Approximation: the sum of amount_paid of paid invoices, by the week the invoice was created.\n// No proration, refunds or churn, and tax is included. It is not MRR.\nconst { from, weeks } = $('Date range').first().json;\nconst WEEK = 7 * 86400;\nconst pages = $input.all().map((item) => item.json);\nif (pages.some((page) => !Array.isArray(page.data))) throw new Error('Stripe did not return a list of invoices. Check the Stripe credential.');\nif (pages.at(-1)?.has_more) throw new Error('More than 2000 paid invoices in 12 weeks: the figure would be wrong. Narrow the query.');\nconst invoices = pages.flatMap((page) => page.data).filter((inv) => inv.status === 'paid' && inv.created >= from && inv.created < from + weeks * WEEK);\nif (invoices.length === 0) throw new Error('No paid invoices in the last 12 weeks, so there is nothing to chart.');\n\n// One currency per chart: the one with the most money. Invoices in others are counted and left out.\nconst byCurrency = {};\nfor (const inv of invoices) {\n  const rows = (byCurrency[inv.currency] ??= new Array(weeks).fill(0));\n  rows[Math.floor((inv.created - from) / WEEK)] += inv.amount_paid;\n}\nconst sum = (rows) => rows.reduce((a, b) => a + b, 0);\nconst currency = Object.keys(byCurrency).sort((a, b) => sum(byCurrency[b]) - sum(byCurrency[a]))[0];\nconst skipped = invoices.filter((inv) => inv.currency !== currency).length;\n\n// Stripe amounts are in the currency's minor unit. ISK and UGX are zero-decimal but Stripe\n// still sends them with two decimals.\nconst digits = ['isk', 'ugx'].includes(currency) ? 2 : new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits;\nconst values = byCurrency[currency].map((minor) => Math.round(minor / 10 ** digits));\nconst symbol = new Intl.NumberFormat('en', { style: 'currency', currency, currencyDisplay: 'narrowSymbol' }).formatToParts(0).find((p) => p.type === 'currency').value;\nconst prefix = symbol.length > 1 ? symbol + ' ' : symbol;\n\nconst MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\nconst labels = values.map((_, i) => {\n  const d = new Date((from + i * WEEK) * 1000);\n  return MONTHS[d.getUTCMonth()] + ' ' + d.getUTCDate();\n});\n\nconst last = values.at(-1);\nconst previous = values.at(-2);\nconst money = (n) => prefix + n.toLocaleString('en');\nconst change = previous > 0 ? Math.round(((last - previous) / previous) * 1000) / 10 : null;\nconst trend = change === null ? 'there was no revenue the week before' : change === 0 ? 'unchanged on the week before' : (change > 0 ? 'up ' : 'down ') + Math.abs(change) + '% on the week before';\nconst fallback = 'Weekly revenue: ' + money(last) + (change === null ? '' : ' (' + (change > 0 ? '+' : '') + change + '%)');\nconst alt = 'Weekly revenue was ' + money(last) + ' in the week of ' + labels.at(-1) + ', ' + trend + '. Last 12 weeks of paid Stripe invoices.';\n\nconst chart = {\n  type: 'line',\n  data: { labels, datasets: [{ label: 'Revenue', data: values }] },\n  options: { plugins: { title: { display: true, text: 'Weekly revenue' }, subtitle: { display: true, text: 'Paid invoices, last 12 weeks' }, chartpng: { preset: 'kpi', previous, prefix, source: 'Stripe, paid invoices' } } },\n};\nconst imageUrl = 'https://chartpng.dev/chart?c=' + encodeURIComponent(JSON.stringify(chart)) + '&w=800&h=400&dpr=2';\n// Slack refuses an image block whose URL is longer than 3000 characters.\nif (imageUrl.length > 3000) throw new Error('The chart URL is longer than 3000 characters.');\n\nconst note = 'Approximate: the sum of paid Stripe invoices by week (UTC, Monday to Sunday), tax included. No proration, refunds or churn.' + (skipped ? ' ' + skipped + ' invoice(s) in other currencies left out.' : '');\nconst blocks = [\n  { type: 'header', text: { type: 'plain_text', text: 'Weekly revenue' } },\n  { type: 'image', image_url: imageUrl, alt_text: alt },\n  { type: 'context', elements: [{ type: 'mrkdwn', text: note }] },\n];\nreturn [{ json: { imageUrl, fallback, alt, blocks } }];"
      },
      "id": "stripe-build",
      "name": "Build chart URL",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        260
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_CHANNEL_ID",
          "mode": "id"
        },
        "messageType": "block",
        "blocksUi": "={{ JSON.stringify({ blocks: $json.blocks }) }}",
        "text": "={{ $json.fallback }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "stripe-slack",
      "name": "Post to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        960,
        260
      ],
      "credentials": {
        "slackApi": {
          "id": "REPLACE_WITH_CREDENTIAL_ID",
          "name": "Slack account"
        }
      }
    }
  ],
  "connections": {
    "Every Monday": {
      "main": [
        [
          {
            "node": "Date range",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Date range": {
      "main": [
        [
          {
            "node": "List paid invoices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List paid invoices": {
      "main": [
        [
          {
            "node": "Build chart URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build chart URL": {
      "main": [
        [
          {
            "node": "Post to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "tags": []
}
