{
  "info": {
    "_postman_id": "9f3c1d20-5a41-4e77-9c0e-2026081100gl",
    "name": "Beta Reliable Cargo - General Ledger",
    "description": "Every General Ledger endpoint, plus the login that supplies the token.\n\n## Which server\n\n`baseUrl` points at **staging** (`https://staging-api.betareliablecargo.com/api`). Run everything there first: this collection writes real journal entries, and staging is where that costs nothing.\n\nProduction is `https://api.betareliablecargo.com/api`. Switch `baseUrl` only once staging is green, and note that the login credentials are whatever that database holds - a staging login may not be your production login.\n\n## Setup\n\n1. Open the collection **Variables** tab and fill in `email` and `security_key`.\n2. Run **00 Auth / Login** once. It stores the bearer token in the `token` variable and every other request picks it up automatically.\n3. Run **01 Chart of Accounts / List accounts** next. It stores `arAccountId`, `revenueAccountId` and `cashAccountId`, which the journal examples use, so the request bodies stay valid on any database.\n\nAfter that the folders can be run in order - or the whole collection with the Postman runner, which is the fastest way to prove the ledger works end to end.\n\n## What the tests assert\n\nEach request carries assertions, not just an example. The negative cases (unbalanced entry, posting into a closed period, editing a posted entry) assert that the API **refuses** them - those are the ones that prove double-entry is actually being enforced rather than merely documented.\n\n## Currency\n\nEntries are recorded in the currency of their source document and the reports group by currency. Nothing is converted between currencies - this system has no exchange rate table.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{token}}",
        "type": "string"
      }
    ]
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// Dates used by the example bodies, so they are always valid today.",
          "const now = new Date();",
          "const pad = n => String(n).padStart(2, '0');",
          "const iso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;",
          "",
          "pm.collectionVariables.set('today', iso);",
          "pm.collectionVariables.set('currentYear', String(now.getFullYear()));",
          "pm.collectionVariables.set('yearStart', `${now.getFullYear()}-01-01`);",
          "pm.collectionVariables.set('nextYear', String(now.getFullYear() + 1));"
        ]
      }
    }
  ],
  "variable": [
    {
      "key": "baseUrl",
      "value": "https://staging-api.betareliablecargo.com/api",
      "type": "string",
      "description": "Staging. Production is https://api.betareliablecargo.com/api - switch only once staging has passed."
    },
    {
      "key": "email",
      "value": "",
      "type": "string"
    },
    {
      "key": "security_key",
      "value": "",
      "type": "string"
    },
    {
      "key": "token",
      "value": "",
      "type": "string"
    },
    {
      "key": "arAccountId",
      "value": "",
      "type": "string"
    },
    {
      "key": "revenueAccountId",
      "value": "",
      "type": "string"
    },
    {
      "key": "cashAccountId",
      "value": "",
      "type": "string"
    },
    {
      "key": "headerAccountId",
      "value": "",
      "type": "string"
    },
    {
      "key": "glAccountId",
      "value": "",
      "type": "string"
    },
    {
      "key": "entryId",
      "value": "",
      "type": "string"
    },
    {
      "key": "postedEntryId",
      "value": "",
      "type": "string"
    },
    {
      "key": "periodId",
      "value": "",
      "type": "string"
    },
    {
      "key": "today",
      "value": "",
      "type": "string"
    },
    {
      "key": "yearStart",
      "value": "",
      "type": "string"
    },
    {
      "key": "currentYear",
      "value": "",
      "type": "string"
    },
    {
      "key": "nextYear",
      "value": "",
      "type": "string"
    }
  ],
  "item": [
    {
      "name": "00 Auth",
      "description": "Run Login once. Everything else depends on the token it stores.",
      "item": [
        {
          "name": "Login",
          "request": {
            "auth": {
              "type": "noauth"
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"{{email}}\",\n  \"security_key\": \"{{security_key}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/auth/login",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "auth",
                "login"
              ]
            },
            "description": "Fill `email` and `security_key` in the collection variables first. The token is stored automatically."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('logged in', () => pm.response.to.have.status(200));",
                  "",
                  "const token = pm.response.json()?.data?.token;",
                  "pm.test('token returned', () => pm.expect(token).to.be.a('string').and.not.empty);",
                  "",
                  "if (token) {",
                  "    pm.collectionVariables.set('token', token);",
                  "    console.log('token stored');",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Profile (check the token works)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/auth/profile",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "auth",
                "profile"
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('token is accepted', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "No token is rejected",
          "request": {
            "auth": {
              "type": "noauth"
            },
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-accounts",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-accounts"
              ]
            },
            "description": "The ledger is not public. Without a bearer token every accounting route must answer 401."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('401 without a token', () => pm.response.to.have.status(401));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "01 Chart of Accounts",
      "description": "The list of accounts every journal line points at. Run **List accounts** before the journal folder - it stores the account ids the examples use.",
      "item": [
        {
          "name": "List accounts",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-accounts?active=1",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-accounts"
              ],
              "query": [
                {
                  "key": "active",
                  "value": "1",
                  "description": "Omit to include inactive accounts too."
                }
              ]
            },
            "description": "Stores arAccountId (1200), revenueAccountId (4000), cashAccountId (the first bank account) and headerAccountId (1000), so the journal examples work on any database."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const accounts = pm.response.json()?.data || [];",
                  "pm.test('chart of accounts is seeded', () => pm.expect(accounts.length).to.be.above(0));",
                  "",
                  "const byCode = code => accounts.find(a => a.account_code === code);",
                  "const cash = accounts.find(a => a.link_type === 'cash_account' && Number(a.is_postable) === 1);",
                  "",
                  "const store = (name, account) => {",
                  "    if (account) pm.collectionVariables.set(name, String(account.gl_account_id));",
                  "};",
                  "",
                  "store('arAccountId', byCode('1200'));",
                  "store('revenueAccountId', byCode('4000'));",
                  "store('headerAccountId', byCode('1000'));",
                  "store('cashAccountId', cash);",
                  "",
                  "pm.test('AR control account exists', () => pm.expect(byCode('1200')).to.not.be.undefined);",
                  "pm.test('revenue account exists', () => pm.expect(byCode('4000')).to.not.be.undefined);",
                  "pm.test('at least one bank account is mapped', () => pm.expect(cash).to.not.be.undefined);",
                  "",
                  "// A heading groups other accounts and must never take a posting.",
                  "pm.test('1000 Cash and Bank is a heading', () => {",
                  "    pm.expect(Number(byCode('1000')?.is_postable)).to.eql(0);",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Create account",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"account_code\": \"5300\",\n  \"account_name\": \"Fuel Expenses\",\n  \"account_type\": \"expense\",\n  \"parent_id\": null,\n  \"is_postable\": 1,\n  \"description\": \"Fuel bought for the fleet\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-accounts",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-accounts"
              ]
            },
            "description": "account_type is one of asset, liability, equity, revenue, expense. The side the account increases on (its normal balance) is derived from the type and cannot be set by hand.\n\nSet is_postable to 0 for a heading that only groups other accounts."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "// 201 on the first run; 400 'already exists' if you run it again.",
                  "pm.test('created or already present', () => pm.expect(pm.response.code).to.be.oneOf([201, 400]));",
                  "",
                  "const id = pm.response.json()?.data?.gl_account_id;",
                  "if (id) pm.collectionVariables.set('glAccountId', String(id));"
                ]
              }
            }
          ]
        },
        {
          "name": "Create account - duplicate code is refused",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"account_code\": \"1200\",\n  \"account_name\": \"Another receivable\",\n  \"account_type\": \"asset\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-accounts",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-accounts"
              ]
            },
            "description": "Account codes are unique. Two accounts sharing a code would make every report ambiguous."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test('says the code is taken', () => {",
                  "    pm.expect(pm.response.json()?.message).to.include('already exists');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Get account by id",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-accounts/{{arAccountId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-accounts",
                "{{arAccountId}}"
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Update account",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"account_code\": \"5300\",\n  \"account_name\": \"Fuel and Lubricants\",\n  \"account_type\": \"expense\",\n  \"is_postable\": 1,\n  \"status\": 1,\n  \"description\": \"Fuel and oil for the fleet\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-accounts/{{glAccountId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-accounts",
                "{{glAccountId}}"
              ]
            },
            "description": "Set status to 0 to retire an account instead of deleting it - deleting would break the history that points at it."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Update account - type is frozen once it carries postings",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"account_type\": \"revenue\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-accounts/{{arAccountId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-accounts",
                "{{arAccountId}}"
              ]
            },
            "description": "Turning Accounts Receivable into a revenue account would silently rewrite the meaning of every entry already posted to it, so it is refused.\n\nThis assertion only holds once something has been posted to 1200 - run the sync first."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "// 400 once 1200 carries lines; 200 on a ledger that is still empty.",
                  "pm.test('refused, or the ledger is still empty', () => {",
                  "    pm.expect(pm.response.code).to.be.oneOf([400, 200]);",
                  "});",
                  "",
                  "if (pm.response.code === 400) {",
                  "    pm.test('explains why', () => {",
                  "        pm.expect(pm.response.json()?.message).to.include('journal lines');",
                  "    });",
                  "} else {",
                  "    console.log('1200 has no postings yet - run 04 Journal entries / Sync first for this check to be meaningful');",
                  "}"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "02 Posting rules",
      "description": "Which account each document type hits when it reaches the ledger. This is what makes invoices, payments, notes and expenses post themselves.",
      "item": [
        {
          "name": "Get posting rules",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-settings",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-settings"
              ]
            },
            "description": "Nine rules: ap_control, ar_control, bank_charges, default_cash, default_expense, purchase_expense, retained_earnings, sales_revenue, vat_payable."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const rules = pm.response.json()?.data || [];",
                  "pm.test('all nine rules exist', () => pm.expect(rules.length).to.eql(9));",
                  "",
                  "// An unset rule means the documents it governs cannot be posted at all.",
                  "const unset = rules.filter(r => !r.gl_account_id).map(r => r.setting_key);",
                  "pm.test('no rule is left unset', () => pm.expect(unset).to.eql([]));"
                ]
              }
            }
          ]
        },
        {
          "name": "Update posting rules",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"settings\": {\n    \"ar_control\": {{arAccountId}},\n    \"sales_revenue\": {{revenueAccountId}},\n    \"default_cash\": {{cashAccountId}}\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-settings",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-settings"
              ]
            },
            "description": "Send only the rules you want to change. Changing a rule affects future postings only - entries already in the ledger keep the account they were posted to."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Update posting rules - heading account is refused",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"settings\": {\n    \"ar_control\": {{headerAccountId}}\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/gl-settings",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "gl-settings"
              ]
            },
            "description": "1000 Cash and Bank is a heading. Pointing a posting rule at it would produce entries on an account that is not allowed to hold any."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test('explains why', () => {",
                  "    pm.expect(pm.response.json()?.message).to.include('header account');",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "03 Fiscal periods",
      "description": "Closing a period locks it. Nothing can be posted or reversed into a closed period, which is what keeps a signed-off month signed off.",
      "item": [
        {
          "name": "List periods",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/periods?year={{currentYear}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "periods"
              ],
              "query": [
                {
                  "key": "year",
                  "value": "{{currentYear}}",
                  "description": "Omit for every year."
                }
              ]
            },
            "description": "Stores the id of the period containing today, for the close/reopen requests below."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const periods = pm.response.json()?.data || [];",
                  "pm.test('the year has twelve periods', () => pm.expect(periods.length).to.eql(12));",
                  "",
                  "const today = pm.collectionVariables.get('today');",
                  "const current = periods.find(p => p.start_date <= today && today <= p.end_date);",
                  "if (current) pm.collectionVariables.set('periodId', String(current.period_id));"
                ]
              }
            }
          ]
        },
        {
          "name": "Generate a year of periods",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"year\": {{nextYear}}\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/periods/generate",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "periods",
                "generate"
              ]
            },
            "description": "Creates the twelve calendar months of a year. Safe to run twice - months that already exist are left alone.\n\nYou rarely need this: an entry dated in a year that has no periods gets its month created automatically, open."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Close a period",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/periods/{{periodId}}/close",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "periods",
                "{{periodId}}",
                "close"
              ]
            },
            "description": "Refused while the period still holds draft entries - those drafts could never be posted again.\n\nCareful on a live database: this closes the current month. Reopen it with the next request."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "// 400 with an explanation if the period still holds drafts.",
                  "pm.test('closed, or refused with a reason', () => {",
                  "    pm.expect(pm.response.code).to.be.oneOf([200, 400]);",
                  "});",
                  "",
                  "if (pm.response.code === 400) {",
                  "    console.log(pm.response.json()?.message);",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Post into a closed period is refused",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"entry_date\": \"{{today}}\",\n  \"description\": \"Should not be accepted while the period is closed\",\n  \"post\": true,\n  \"lines\": [\n    { \"gl_account_id\": {{arAccountId}}, \"debit\": 1000, \"credit\": 0 },\n    { \"gl_account_id\": {{revenueAccountId}}, \"debit\": 0, \"credit\": 1000 }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals"
              ]
            },
            "description": "Run this straight after Close a period. A balanced, otherwise valid entry must still be refused because its month is locked."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const data = pm.response.json();",
                  "",
                  "if (pm.response.code === 400) {",
                  "    pm.test('refused because the period is closed', () => {",
                  "        pm.expect(data?.message).to.include('closed');",
                  "    });",
                  "} else {",
                  "    // The close above did not take (usually drafts in the period), so this",
                  "    // entry really was posted. Undo it rather than leave it in the books.",
                  "    console.log('period was not closed - reversing the entry this created');",
                  "    reverseEntry(data?.data?.entry_id);",
                  "}",
                  "",
                  "function reverseEntry(entryId) {",
                  "    if (!entryId) return;",
                  "",
                  "    pm.sendRequest({",
                  "        url: `${pm.collectionVariables.get('baseUrl')}/accounting/journals/${entryId}/reverse`,",
                  "        method: 'POST',",
                  "        header: {",
                  "            'Content-Type': 'application/json',",
                  "            'Authorization': `Bearer ${pm.collectionVariables.get('token')}`",
                  "        },",
                  "        body: {",
                  "            mode: 'raw',",
                  "            raw: JSON.stringify({ reason: 'Postman example - reversed automatically' })",
                  "        }",
                  "    }, (err, res) => {",
                  "        console.log(err ? `auto-reversal failed: ${err}` : `auto-reversed with ${res.json()?.data?.entry_number}`);",
                  "    });",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Reopen a period",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/periods/{{periodId}}/reopen",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "periods",
                "{{periodId}}",
                "reopen"
              ]
            },
            "description": "Run this after the closed-period test so the current month is usable again."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('reopened, or was never closed', () => {",
                  "    pm.expect(pm.response.code).to.be.oneOf([200, 400]);",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "04 Journal entries",
      "description": "The double-entry record. Debits must equal credits, a line is either a debit or a credit, and a posted entry is immutable - mistakes are undone with a reversal, never an edit.",
      "item": [
        {
          "name": "List entries",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/journals?from={{yearStart}}&to={{today}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals"
              ],
              "query": [
                {
                  "key": "from",
                  "value": "{{yearStart}}"
                },
                {
                  "key": "to",
                  "value": "{{today}}"
                },
                {
                  "key": "status",
                  "value": "posted",
                  "description": "draft | posted | reversed",
                  "disabled": true
                },
                {
                  "key": "journal_type",
                  "value": "sales",
                  "description": "general | sales | purchase | cash_receipt | cash_payment | opening | adjustment",
                  "disabled": true
                },
                {
                  "key": "source_type",
                  "value": "client_invoice",
                  "description": "client_invoice | client_payment | credit_note | vendor_invoice | vendor_payment | debit_note | expense",
                  "disabled": true
                },
                {
                  "key": "gl_account_id",
                  "value": "{{arAccountId}}",
                  "description": "Only entries touching this account.",
                  "disabled": true
                },
                {
                  "key": "search",
                  "value": "INV",
                  "description": "Matches entry number, reference or description.",
                  "disabled": true
                },
                {
                  "key": "with_lines",
                  "value": "1",
                  "description": "Include each entry's lines in the list.",
                  "disabled": true
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const entries = pm.response.json()?.data || [];",
                  "",
                  "// Every entry must balance, whatever produced it.",
                  "const unbalanced = entries.filter(e =>",
                  "    Math.round(Number(e.total_debit) * 100) !== Math.round(Number(e.total_credit) * 100)",
                  ");",
                  "pm.test('every entry balances', () => pm.expect(unbalanced).to.eql([]));",
                  "",
                  "const posted = entries.find(e => e.status === 'posted' && !e.reversal_of);",
                  "if (posted) pm.collectionVariables.set('postedEntryId', String(posted.entry_id));"
                ]
              }
            }
          ]
        },
        {
          "name": "Create entry - unbalanced is refused",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"entry_date\": \"{{today}}\",\n  \"description\": \"Debits and credits do not agree\",\n  \"lines\": [\n    { \"gl_account_id\": {{arAccountId}}, \"debit\": 1000, \"credit\": 0 },\n    { \"gl_account_id\": {{revenueAccountId}}, \"debit\": 0, \"credit\": 800 }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals"
              ]
            },
            "description": "The rule the whole ledger rests on. The message names the difference so the mistake is obvious."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test('names the difference', () => {",
                  "    pm.expect(pm.response.json()?.message).to.include('200.00');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Create entry - a line cannot be both debit and credit",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"entry_date\": \"{{today}}\",\n  \"description\": \"Line 1 carries both sides\",\n  \"lines\": [\n    { \"gl_account_id\": {{arAccountId}}, \"debit\": 1000, \"credit\": 200 },\n    { \"gl_account_id\": {{revenueAccountId}}, \"debit\": 0, \"credit\": 800 }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals"
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test('points at the line', () => {",
                  "    pm.expect(pm.response.json()?.message).to.include('Line 1');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Create entry - heading account is refused",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"entry_date\": \"{{today}}\",\n  \"description\": \"Posting to a grouping account\",\n  \"lines\": [\n    { \"gl_account_id\": {{headerAccountId}}, \"debit\": 1000, \"credit\": 0 },\n    { \"gl_account_id\": {{revenueAccountId}}, \"debit\": 0, \"credit\": 1000 }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals"
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test('says it is a heading', () => {",
                  "    pm.expect(pm.response.json()?.message).to.include('header account');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Create entry - draft",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"entry_date\": \"{{today}}\",\n  \"journal_type\": \"general\",\n  \"reference\": \"POSTMAN-001\",\n  \"description\": \"Draft raised from Postman\",\n  \"currency_code\": \"RWF\",\n  \"post\": false,\n  \"lines\": [\n    {\n      \"gl_account_id\": {{arAccountId}},\n      \"debit\": 250000,\n      \"credit\": 0,\n      \"description\": \"Amount owed by the client\"\n    },\n    {\n      \"gl_account_id\": {{revenueAccountId}},\n      \"debit\": 0,\n      \"credit\": 250000,\n      \"description\": \"Service revenue earned\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals"
              ]
            },
            "description": "post: false saves a draft. A draft is not part of the books and does not appear in any report until it is posted.\n\njournal_type is one of general, sales, purchase, cash_receipt, cash_payment, opening, adjustment. It decides the entry number prefix (JV, SI, PI, CR, CP, OB, AJ).\n\nA line may also carry client_id, vendor_id or account_id to say who or which bank account it is about."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('201', () => pm.response.to.have.status(201));",
                  "",
                  "const data = pm.response.json()?.data;",
                  "pm.test('entry number issued', () => pm.expect(data?.entry_number).to.match(/^JV-\\d{4}-\\d{5}$/));",
                  "",
                  "if (data?.entry_id) pm.collectionVariables.set('entryId', String(data.entry_id));"
                ]
              }
            }
          ]
        },
        {
          "name": "Get entry by id",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/{{entryId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "{{entryId}}"
              ]
            },
            "description": "Returns the header plus its lines, each with the account code and name."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const entry = pm.response.json()?.data;",
                  "pm.test('lines come back', () => pm.expect(entry?.lines?.length).to.be.above(1));",
                  "pm.test('still a draft', () => pm.expect(entry?.status).to.eql('draft'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Update draft",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"entry_date\": \"{{today}}\",\n  \"reference\": \"POSTMAN-001\",\n  \"description\": \"Draft corrected before posting\",\n  \"currency_code\": \"RWF\",\n  \"lines\": [\n    {\n      \"gl_account_id\": {{arAccountId}},\n      \"debit\": 300000,\n      \"credit\": 0,\n      \"description\": \"Corrected amount\"\n    },\n    {\n      \"gl_account_id\": {{revenueAccountId}},\n      \"debit\": 0,\n      \"credit\": 300000,\n      \"description\": \"Corrected revenue\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/{{entryId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "{{entryId}}"
              ]
            },
            "description": "The lines you send replace the ones stored. Drafts only - a posted entry refuses this."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Post the draft",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/{{entryId}}/post",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "{{entryId}}",
                "post"
              ]
            },
            "description": "Moves the entry into the books. The balance is re-checked here, not just at creation."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Post again is refused",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/{{entryId}}/post",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "{{entryId}}",
                "post"
              ]
            },
            "description": "Posting twice would double every amount in the entry."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test('says it is already posted', () => {",
                  "    pm.expect(pm.response.json()?.message).to.include('already posted');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Edit a posted entry is refused",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"description\": \"Trying to rewrite history\",\n  \"lines\": [\n    { \"gl_account_id\": {{arAccountId}}, \"debit\": 1, \"credit\": 0 },\n    { \"gl_account_id\": {{revenueAccountId}}, \"debit\": 0, \"credit\": 1 }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/{{entryId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "{{entryId}}"
              ]
            },
            "description": "Immutability is the point: a posted entry that can be edited is a set of books nobody can trust."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test('points to reversal instead', () => {",
                  "    pm.expect(pm.response.json()?.message).to.include('Reverse');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Delete a posted entry is refused",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/{{entryId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "{{entryId}}"
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test('points to reversal instead', () => {",
                  "    pm.expect(pm.response.json()?.message).to.include('Reverse');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Reverse the posted entry",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"reason\": \"Raised against the wrong client\",\n  \"reversal_date\": \"{{today}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/{{entryId}}/reverse",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "{{entryId}}",
                "reverse"
              ]
            },
            "description": "Books the mirror image and marks the original reversed. Both stay in the ledger and cancel each other out - that is what makes the correction auditable.\n\nreversal_date is optional; it defaults to today, or to the entry's own date if that is later. It may never be earlier than the entry it reverses."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "pm.test('a reversing entry was created', () => {",
                  "    pm.expect(pm.response.json()?.data?.entry_number).to.be.a('string');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Create entry - save and post in one step",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"entry_date\": \"{{today}}\",\n  \"journal_type\": \"general\",\n  \"reference\": \"POSTMAN-VAT\",\n  \"description\": \"Invoice of 1,000,000 plus 18% VAT\",\n  \"currency_code\": \"RWF\",\n  \"post\": true,\n  \"lines\": [\n    {\n      \"gl_account_id\": {{arAccountId}},\n      \"debit\": 1180000,\n      \"credit\": 0,\n      \"description\": \"Total owed by the client\"\n    },\n    {\n      \"gl_account_id\": {{revenueAccountId}},\n      \"debit\": 0,\n      \"credit\": 1000000,\n      \"description\": \"Service revenue, VAT excluded\"\n    },\n    {\n      \"gl_account_id\": {{cashAccountId}},\n      \"debit\": 0,\n      \"credit\": 180000,\n      \"description\": \"Replace this account with 2300 VAT Payable on a real entry\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals"
              ]
            },
            "description": "A three-line entry - the shape the old single-debit/single-credit table could not express.\n\nThe third line uses the cash account only so the example runs without extra setup. On a real VAT entry point it at 2300 VAT Payable."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('201', () => pm.response.to.have.status(201));",
                  "pm.test('posted immediately', () => {",
                  "    pm.expect(pm.response.json()?.message).to.include('posted');",
                  "});",
                  "",
                  "// This example lands 1,180,000 in the real books, so it reverses itself.",
                  "// The pair nets to zero and both halves stay visible, which is exactly how",
                  "// a correction is supposed to look.",
                  "const entryId = pm.response.json()?.data?.entry_id;",
                  "",
                  "if (entryId) {",
                  "    pm.sendRequest({",
                  "        url: `${pm.collectionVariables.get('baseUrl')}/accounting/journals/${entryId}/reverse`,",
                  "        method: 'POST',",
                  "        header: {",
                  "            'Content-Type': 'application/json',",
                  "            'Authorization': `Bearer ${pm.collectionVariables.get('token')}`",
                  "        },",
                  "        body: {",
                  "            mode: 'raw',",
                  "            raw: JSON.stringify({ reason: 'Postman example - reversed automatically' })",
                  "        }",
                  "    }, (err, res) => {",
                  "        console.log(err ? `auto-reversal failed: ${err}` : `auto-reversed with ${res.json()?.data?.entry_number}`);",
                  "    });",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Delete a draft",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/{{entryId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "{{entryId}}"
              ]
            },
            "description": "Only a draft can be deleted. Create a fresh draft first if you want to see this succeed - by this point in the run entryId has been posted and reversed."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('deleted, or refused because it is not a draft', () => {",
                  "    pm.expect(pm.response.code).to.be.oneOf([200, 400]);",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Sync from documents",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/sync",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "sync"
              ]
            },
            "description": "Generates journal entries for invoices, payments, notes and expenses that have none yet.\n\nIdempotent: a document already in the ledger is skipped, so this is safe to run any number of times. It is both the backfill for everything recorded before the ledger existed and the repair path if a live posting ever failed.\n\nOptional body to narrow it down:\n\n{\n  \"source_types\": [\"expense\", \"client_payment\"],\n  \"from\": \"2026-01-01\",\n  \"to\": \"2026-12-31\"\n}\n\nsource_types: client_invoice, client_payment, credit_note, vendor_invoice, vendor_payment, debit_note, expense."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const data = pm.response.json()?.data;",
                  "console.log('summary', JSON.stringify(data?.summary));",
                  "",
                  "// A document that cannot be posted usually means a posting rule is unset.",
                  "pm.test('nothing failed to post', () => {",
                  "    if (data?.error_count) console.log('errors', JSON.stringify(data.errors));",
                  "    pm.expect(data?.error_count).to.eql(0);",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Sync again changes nothing",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/accounting/journals/sync",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "journals",
                "sync"
              ]
            },
            "description": "Run straight after the previous request. Proves the sync cannot double-post."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const summary = pm.response.json()?.data?.summary || {};",
                  "const posted = Object.values(summary).reduce((total, c) => total + (c.posted || 0), 0);",
                  "",
                  "pm.test('nothing posted a second time', () => pm.expect(posted).to.eql(0));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "05 Reports",
      "description": "Built from posted entries. Reversed entries are included on purpose - the original and its reversal cancel each other out, and dropping the original would swing every balance the wrong way.",
      "item": [
        {
          "name": "Trial balance",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/reports/trial-balance?from={{yearStart}}&to={{today}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "reports",
                "trial-balance"
              ],
              "query": [
                {
                  "key": "from",
                  "value": "{{yearStart}}",
                  "description": "Omit for balances from the very beginning."
                },
                {
                  "key": "to",
                  "value": "{{today}}",
                  "description": "Defaults to today."
                }
              ]
            },
            "description": "Opening balance, movement over the period and closing balance per account, grouped by currency. The health check for the whole ledger: total debit must equal total credit."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const currencies = pm.response.json()?.data?.currencies || [];",
                  "",
                  "currencies.forEach(block => {",
                  "    pm.test(`${block.currency}: debits equal credits`, () => {",
                  "        pm.expect(block.balanced, `difference ${block.difference}`).to.be.true;",
                  "    });",
                  "});",
                  "",
                  "if (!currencies.length) {",
                  "    console.log('no posted entries yet - run the sync first');",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "General ledger for one account",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/reports/general-ledger?gl_account_id={{arAccountId}}&from={{yearStart}}&to={{today}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "reports",
                "general-ledger"
              ],
              "query": [
                {
                  "key": "gl_account_id",
                  "value": "{{arAccountId}}",
                  "description": "Required."
                },
                {
                  "key": "from",
                  "value": "{{yearStart}}"
                },
                {
                  "key": "to",
                  "value": "{{today}}"
                }
              ]
            },
            "description": "Every movement on one account with the balance carried down line by line, starting from the balance the account already held before `from`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const blocks = pm.response.json()?.data?.currencies || [];",
                  "",
                  "// The closing balance must be the opening balance plus the movement.",
                  "blocks.forEach(block => {",
                  "    const expected = Math.round((block.opening_balance + block.total_debit - block.total_credit) * 100);",
                  "    pm.test(`${block.currency}: running balance adds up`, () => {",
                  "        pm.expect(Math.round(block.closing_balance * 100)).to.eql(expected);",
                  "    });",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "General ledger without an account is refused",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/reports/general-ledger",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "reports",
                "general-ledger"
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));"
                ]
              }
            }
          ]
        },
        {
          "name": "Balance sheet",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounting/reports/balance-sheet?as_of={{today}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "accounting",
                "reports",
                "balance-sheet"
              ],
              "query": [
                {
                  "key": "as_of",
                  "value": "{{today}}",
                  "description": "Defaults to today."
                }
              ]
            },
            "description": "Assets, liabilities and equity as at a date. Revenue and expenses are not closed into equity by a year-end routine, so the accumulated result appears as its own equity line - which is what makes the statement balance."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "",
                  "const currencies = pm.response.json()?.data?.currencies || [];",
                  "",
                  "currencies.forEach(block => {",
                  "    pm.test(`${block.currency}: assets = liabilities + equity`, () => {",
                  "        pm.expect(block.balanced, `difference ${block.difference}`).to.be.true;",
                  "    });",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    }
  ]
}
