{
  "openapi": "3.1.0",
  "info": {
    "title": "ReconSmith API",
    "version": "1.0.0",
    "description": "Accounting ledger with explainable settlement. Every write takes an Idempotency-Key; every list is cursor-paged; there is no daily call cap."
  },
  "servers": [
    {
      "url": "https://api.reconsmith.com"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key issued per client."
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string",
          "maxLength": 255
        },
        "description": "Required on every write. Retrying with the same key returns the original response instead of creating a duplicate. Reusing a key with a different body is a 422."
      }
    }
  },
  "paths": {
    "/health": {
      "get": {
        "summary": "Liveness and database reachability",
        "security": [],
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/v1/parties": {
      "post": {
        "summary": "Create or update parties in bulk",
        "description": "Keyed on (source_system, external_ref). Safe to replay: an existing party is returned, not duplicated. A changed name is recorded as a rename, so history is preserved.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "maxItems": 1000,
                "items": {
                  "type": "object",
                  "required": [
                    "external_ref",
                    "name"
                  ],
                  "properties": {
                    "external_ref": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "source_system": {
                      "type": "string",
                      "default": "gastrac"
                    },
                    "party_type": {
                      "type": "string",
                      "enum": [
                        "ORGANISATION",
                        "PERSON"
                      ]
                    },
                    "role": {
                      "type": "string",
                      "enum": [
                        "CUSTOMER",
                        "SUPPLIER",
                        "EMPLOYEE"
                      ]
                    },
                    "email": {
                      "type": "string",
                      "description": "Required when party_type is PERSON."
                    },
                    "ccy": {
                      "type": "string",
                      "default": "ZAR",
                      "description": "Currency of the account provisioned for this party. One account per currency."
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Parties upserted. Each result carries the account_code the party is invoiced against — provisioned here, by ReconSmith, because the caller has no way to keep a code unique within a ledger. Replaying returns the same code; no second account is created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "count": {
                      "type": "integer"
                    },
                    "parties": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "party_id",
                          "external_ref",
                          "created",
                          "account_code",
                          "sub_account_id",
                          "account_created"
                        ],
                        "properties": {
                          "party_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "external_ref": {
                            "type": "string"
                          },
                          "created": {
                            "type": "boolean",
                            "description": "The party was created by this call."
                          },
                          "account_code": {
                            "type": "string",
                            "description": "Pass this as account_code on /v1/invoices and /v1/payments."
                          },
                          "sub_account_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "account_created": {
                            "type": "boolean",
                            "description": "The account was provisioned by this call."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Validation or idempotency conflict"
          }
        }
      }
    },
    "/v1/parties/search": {
      "get": {
        "summary": "Smart customer lookup",
        "description": "Searches names, FORMER names, VAT and company numbers, external refs, account codes, branch numbers, e-mail and address text. Every hit carries matched_via so the caller can show why it appeared.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 20,
              "maximum": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ranked matches"
          }
        }
      }
    },
    "/v1/purchase-invoices": {
      "post": {
        "summary": "Post supplier bills in bulk",
        "description": "Calls rs.post_purchase_invoice: one balanced journal, an open-period check, and the payable account resolved from the party.\n\nUnlike /v1/invoices, supplier_doc_no IS required. A supplier's invoice number is theirs, not ours \u2014 we neither mint it nor control it, and it is what goes on the remittance so they can find the payment. Where a slip has no number of its own, send your external_ref.\n\nIdempotent on (source_system, external_ref): a repeat returns the existing document with created = false and posts nothing.\n\nTax: leave tax_code off a line and the posting function defaults to SRI, standard rated inputs, VAT201 box 14. For a purchase that carries no VAT \u2014 fuel in South Africa being the usual case \u2014 a zero-tax line is stamped with the entity's zero-rated input code instead (ZRI in South Africa, VOOR0 in the Netherlands). Send tax_code explicitly to override.\n\nDimensions are validated on BOTH the key and the value, and keys are case-sensitive. An unknown dimension or an unregistered value is refused and the whole bill fails. Create them with POST /v1/dimensions first.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "maxItems": 1000,
                "items": {
                  "type": "object",
                  "required": [
                    "external_ref",
                    "supplier_doc_no",
                    "doc_date",
                    "lines"
                  ],
                  "properties": {
                    "external_ref": {
                      "type": "string",
                      "description": "Your identifier, e.g. GT-FUEL-1234. The idempotency key for this document."
                    },
                    "supplier_doc_no": {
                      "type": "string",
                      "description": "The supplier's own invoice or slip number. Send external_ref where there is none."
                    },
                    "supplier_external_ref": {
                      "type": "string",
                      "description": "Your supplier id, as pushed to /v1/parties with role = SUPPLIER."
                    },
                    "account_code": {
                      "type": "string",
                      "description": "Alternative: the account_code returned by /v1/parties."
                    },
                    "doc_date": {
                      "type": "string",
                      "format": "date"
                    },
                    "due_date": {
                      "type": "string",
                      "format": "date",
                      "description": "Omit to let the supplier's terms derive it."
                    },
                    "ccy": {
                      "type": "string",
                      "default": "ZAR"
                    },
                    "reference": {
                      "type": "string",
                      "description": "Printed on the bill and shown on the supplier statement. For fuel, the vehicle registration."
                    },
                    "notes": {
                      "type": "string"
                    },
                    "source_system": {
                      "type": "string",
                      "default": "gastrac"
                    },
                    "lines": {
                      "type": "array",
                      "minItems": 1,
                      "items": {
                        "type": "object",
                        "required": [
                          "description",
                          "amount_excl_tax"
                        ],
                        "properties": {
                          "description": {
                            "type": "string"
                          },
                          "amount_excl_tax": {
                            "type": "number",
                            "description": "The net amount, as it appears on the supplier's document."
                          },
                          "tax_amount": {
                            "type": "number",
                            "default": 0
                          },
                          "account_code": {
                            "type": "string",
                            "description": "The expense account, e.g. 6070 for fuel."
                          },
                          "tax_code": {
                            "type": "string",
                            "description": "Optional. Defaults to SRI, or to the zero-rated input code when tax_amount is 0."
                          },
                          "dimensions": {
                            "type": "object",
                            "additionalProperties": {
                              "type": "string"
                            },
                            "description": "e.g. {\"VEHICLE\": \"JF 24 PV GP\"}. Keys are case-sensitive and both key and value must already exist."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Posted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "count": {
                      "type": "integer"
                    },
                    "purchase_invoices": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "document_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "external_ref": {
                            "type": "string"
                          },
                          "created": {
                            "type": "boolean",
                            "description": "false when this external_ref was already posted."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Unknown supplier, a party with no SUPPLIER account, or an unknown dimension key or value."
          }
        }
      }
    },
    "/v1/dimensions": {
      "get": {
        "summary": "List the dimensions and values this entity accepts",
        "description": "What a line may be tagged with. Worth reading before posting: a line carrying an unknown key or value is refused and the whole document fails.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "post": {
        "summary": "Create dimensions and their values",
        "description": "Needed because dimensions are validated on both the key and the value. A new vehicle registration must exist here before a fuel bill carrying it will post.\n\nCodes are upper-cased: VEHICLE and vehicle are not the same dimension, and only the first exists.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "code",
                    "name",
                    "values"
                  ],
                  "properties": {
                    "code": {
                      "type": "string",
                      "example": "VEHICLE"
                    },
                    "name": {
                      "type": "string",
                      "example": "Vehicle"
                    },
                    "required": {
                      "type": "boolean",
                      "default": false,
                      "description": "true refuses any line of the applicable type that does not carry it."
                    },
                    "applies_to": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "default": [
                        "INCOME",
                        "EXPENSE"
                      ]
                    },
                    "values": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "code"
                        ],
                        "properties": {
                          "code": {
                            "type": "string",
                            "example": "JF 24 PV GP"
                          },
                          "name": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          }
        }
      }
    },
    "/v1/invoices": {
      "post": {
        "summary": "Post sales invoices in bulk",
        "description": "Calls the ledger's own posting function: one balanced journal, an open-period check, terms taken from the billing party, and the receivable account resolved from the party.\n\ndoc_no is NOT accepted. ReconSmith mints the tax invoice number from its own sequence, and takes the number inside the posting transaction, so a failed invoice returns it instead of burning it — the sequence has no gaps. A sales order number legitimately HAS gaps, because orders get cancelled, so passing one through as the invoice number would import those gaps into a sequence an auditor expects to be unbroken. Put the order number in external_ref, and in reference if it should print on the invoice.\n\nNo settlement flag is returned: status is derived from allocations and never stored. Read it from GET /v1/documents.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "maxItems": 1000,
                "items": {
                  "type": "object",
                  "required": [
                    "external_ref",
                    "doc_date",
                    "lines"
                  ],
                  "properties": {
                    "external_ref": {
                      "type": "string",
                      "description": "Your identifier — typically the sales order number. The idempotency key for this document."
                    },
                    "customer_external_ref": {
                      "type": "string",
                      "description": "Your customer id, as pushed to /v1/parties. Preferred: you never learn our codes."
                    },
                    "account_code": {
                      "type": "string",
                      "description": "Alternative: the account_code returned by /v1/parties."
                    },
                    "doc_date": {
                      "type": "string",
                      "format": "date"
                    },
                    "due_date": {
                      "type": "string",
                      "format": "date",
                      "description": "Omit to let the billing party's terms derive it."
                    },
                    "ccy": {
                      "type": "string",
                      "default": "ZAR"
                    },
                    "reference": {
                      "type": "string",
                      "description": "Printed on the invoice. The order number belongs here."
                    },
                    "notes": {
                      "type": "string"
                    },
                    "source_system": {
                      "type": "string",
                      "default": "gastrac"
                    },
                    "lines": {
                      "type": "array",
                      "minItems": 1,
                      "items": {
                        "type": "object",
                        "required": [
                          "description",
                          "amount_excl_tax",
                          "tax_amount"
                        ],
                        "properties": {
                          "description": {
                            "type": "string"
                          },
                          "amount_excl_tax": {
                            "type": "number"
                          },
                          "tax_amount": {
                            "type": "number"
                          },
                          "account_code": {
                            "type": "string",
                            "description": "Revenue account, e.g. \"4010\". Friendlier than carrying our UUIDs in your config."
                          },
                          "ledger_account_id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "Alternative to account_code."
                          },
                          "quantity": {
                            "type": "number",
                            "default": 1
                          },
                          "unit_price": {
                            "type": "number",
                            "description": "Omit and it is derived from amount_excl_tax / quantity."
                          },
                          "branch_id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "Chain customers: the branch that incurred the charge. Its branch_number prints on the statement line."
                          },
                          "site_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "dimensions": {
                            "type": "object",
                            "additionalProperties": {
                              "type": "string"
                            },
                            "description": "Analytic tags. Keys and values must already be registered or the line is refused."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Posted. Each result carries document_id and the external_ref you sent; created:false means it was already posted under that ref."
          },
          "422": {
            "description": "Rejected by the ledger — unknown customer, closed period, unregistered dimension, unbalanced journal. The detail carries the database's own message."
          }
        }
      }
    },
    "/v1/payments": {
      "post": {
        "summary": "Post receipts in bulk",
        "description": "Posts through the ledger's receipt function: Dr bank, Cr receivables control, one balanced journal. The receipt number is minted here. A receipt with nothing allocated to it is a legitimate state — push it when the money arrives and allocate when you know what it paid.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "maxItems": 1000,
                "items": {
                  "type": "object",
                  "required": [
                    "external_ref",
                    "doc_date",
                    "amount"
                  ],
                  "properties": {
                    "external_ref": {
                      "type": "string"
                    },
                    "customer_external_ref": {
                      "type": "string"
                    },
                    "account_code": {
                      "type": "string"
                    },
                    "doc_date": {
                      "type": "string",
                      "format": "date"
                    },
                    "amount": {
                      "type": "number",
                      "description": "Positive. A refund is a credit note plus a payment out, never a negative receipt."
                    },
                    "bank_account_id": {
                      "type": "string",
                      "format": "uuid",
                      "description": "Omit and the entity's active bank account is used."
                    },
                    "reference": {
                      "type": "string"
                    },
                    "source_system": {
                      "type": "string",
                      "default": "gastrac"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          },
          "422": {
            "description": "Rejected by the ledger"
          }
        }
      }
    },
    "/v1/allocations": {
      "post": {
        "summary": "Allocate payments to invoices",
        "description": "Line-granular settlement. One R20 000 receipt clearing four invoices is four allocations, and the statement shows all four under the receipt. Over-allocating is refused by the database, not by the caller's discipline.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "payment_external_ref",
                    "invoice_external_ref",
                    "amount"
                  ],
                  "properties": {
                    "payment_external_ref": {
                      "type": "string"
                    },
                    "invoice_external_ref": {
                      "type": "string"
                    },
                    "amount": {
                      "type": "number"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Allocated"
          },
          "422": {
            "description": "Over-allocation or wrong direction"
          }
        }
      }
    },
    "/v1/accounts/{code}/statement": {
      "get": {
        "summary": "Statement with settlement trace",
        "description": "Each payment line carries settlement_trace: which invoices it settled, for how much, and the branch number for chain customers.",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Statement lines"
          },
          "404": {
            "description": "Unknown account"
          }
        }
      }
    },
    "/v1/accounts/{code}/aged": {
      "get": {
        "summary": "Aged analysis",
        "description": "Ages off due_date, which honours the billing party's terms basis. Statement-terms customers age as one block.",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "asOf",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Buckets"
          }
        }
      }
    },
    "/v1/accounts/{code}/open-items": {
      "get": {
        "summary": "Unsettled documents",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Open items"
          }
        }
      }
    },
    "/v1/changes": {
      "get": {
        "summary": "Change feed",
        "description": "Everything that moved since a cursor. One call instead of polling six endpoints. Cursor-paged, because offsets shift under concurrent writes and a syncing client silently skips rows.",
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int64",
              "default": 0
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 500,
              "maximum": 1000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Changes plus next_cursor"
          }
        }
      }
    },
    "/v1/bank-transactions/{id}/suggestions": {
      "get": {
        "summary": "Reconciliation suggestions",
        "description": "Ranked candidates with the features that produced each score. Layers: exact reference, exact amount, learned memory, fuzzy name. Nothing here posts anything.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Suggestions with confidence bands"
          }
        }
      }
    },
    "/v1/recon/queue": {
      "get": {
        "summary": "Bank lines awaiting review",
        "responses": {
          "200": {
            "description": "Queue"
          }
        }
      }
    },
    "/v1/match-candidates/{id}/accept": {
      "post": {
        "summary": "Accept a suggestion",
        "description": "Writes the match and teaches the matcher. Every other candidate on that line is recorded as rejected — the negative training signal.",
        "responses": {
          "200": {
            "description": "Accepted"
          }
        }
      }
    },
    "/v1/match-candidates/{id}/reject": {
      "post": {
        "summary": "Reject a suggestion",
        "responses": {
          "200": {
            "description": "Rejected"
          }
        }
      }
    },
    "/v1/credit-notes": {
      "post": {
        "summary": "Credit a customer — against an invoice, or on the account",
        "description": "Three shapes, because all three happen:\n\n1. invoice_external_ref, no lines — credit the whole invoice. The ordinary cancelled-order case.\n2. invoice_external_ref with lines — credit part of that invoice.\n3. customer_external_ref with lines, no invoice — a credit note tied to no invoice at all: goodwill, a pricing correction, a deposit refund, a settlement. It is posted against the customer's account and sits there as money owed, and POST /v1/allocations applies it to any later invoice when they spend it.\n\nAn invoice is never deleted and never renumbered. It keeps its number and a credit note cancels it out: that is what keeps the invoice sequence unbroken when an order falls away, and the audit trail append-only. Credit notes have their own gapless sequence (CRN-).\n\napplied: false means nothing was set against an invoice — either the invoice was already paid, or the credit note was never tied to one. Either way the value is credit the customer holds, and it shows in GET /v1/credits and GET /v1/customers/{ref}/credit.\n\nmark_for_refund records that the customer wants the money back rather than a credit against their next order. The credit note is raised either way; this only records which they chose.\n\nreason is never optional. It is the first thing anyone asks about a credit note, and the ledger refuses one without it.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "maxItems": 1000,
                "items": {
                  "type": "object",
                  "required": [
                    "reason",
                    "external_ref"
                  ],
                  "properties": {
                    "invoice_external_ref": {
                      "type": "string",
                      "description": "The invoice being credited. Omit for a credit note on the account, and then lines are required."
                    },
                    "reason": {
                      "type": "string",
                      "description": "Stored on the credit note. Say why — an auditor reads this."
                    },
                    "external_ref": {
                      "type": "string",
                      "description": "Your identifier for the credit note itself."
                    },
                    "doc_date": {
                      "type": "string",
                      "format": "date"
                    },
                    "lines": {
                      "type": "array",
                      "description": "Required when no invoice is named; omit to credit a whole invoice.",
                      "items": {
                        "type": "object",
                        "required": [
                          "description",
                          "amount_excl_tax",
                          "tax_amount"
                        ],
                        "properties": {
                          "description": {
                            "type": "string"
                          },
                          "amount_excl_tax": {
                            "type": "number"
                          },
                          "tax_amount": {
                            "type": "number"
                          },
                          "account_code": {
                            "type": "string",
                            "description": "Revenue account, e.g. \"4010\". Friendlier than carrying our UUIDs in your config."
                          },
                          "ledger_account_id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "Alternative to account_code."
                          },
                          "quantity": {
                            "type": "number",
                            "default": 1
                          },
                          "unit_price": {
                            "type": "number",
                            "description": "Omit and it is derived from amount_excl_tax / quantity."
                          },
                          "branch_id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "Chain customers: the branch that incurred the charge. Its branch_number prints on the statement line."
                          },
                          "site_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "dimensions": {
                            "type": "object",
                            "additionalProperties": {
                              "type": "string"
                            },
                            "description": "Analytic tags. Keys and values must already be registered or the line is refused."
                          }
                        }
                      }
                    },
                    "source_system": {
                      "type": "string",
                      "default": "gastrac"
                    },
                    "mark_for_refund": {
                      "type": "boolean",
                      "default": false,
                      "description": "The customer opted for a refund rather than keeping the credit on account."
                    },
                    "customer_external_ref": {
                      "type": "string",
                      "description": "The customer, for a credit note not tied to an invoice. Your own id, as pushed to /v1/parties."
                    },
                    "account_code": {
                      "type": "string",
                      "description": "Alternative to customer_external_ref."
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Credited"
          },
          "422": {
            "description": "Neither an invoice nor a customer with lines; unknown invoice or customer; no reason; or nothing left to credit on an invoice already settled."
          }
        }
      }
    },
    "/v1/documents": {
      "get": {
        "summary": "Bulk settlement status by your own identifiers",
        "description": "Which of the invoices you pushed are still open. Derived from allocations and never stored, so it is always current — do not cache it as a boolean, it is wrong the moment a receipt is allocated. Refs that match nothing come back under not_found, so a syncing caller sees what never landed instead of inferring it from a short list.\n\nEach row splits the settling side, because 'settled' is not one fact: paid_amount is receipts, credited_amount is credit notes, and state is derived from the two — OPEN, PART_PAID, PAID, PART_CREDITED, CREDITED, PAID_AND_CREDITED or VOIDED. An invoice paid and an invoice cancelled-and-credited both show settlement_status SETTLED, and an app that renders them the same way is lying to the customer. credit_note_nos lists the credit notes against the document, marked_for_refund says the customer asked for the money back, and last_movement_at is when an allocation last touched it. Everything is derived from the allocation rows, so it cannot disagree with the ledger — learn that something moved from GET /v1/changes (ALLOCATED) and re-read rather than caching a flag.",
        "parameters": [
          {
            "name": "externalRefs",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated, up to 1000."
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "document_id, doc_no, doc_type, total_incl_tax, allocated, outstanding, settlement_status, status, reference — plus not_found"
          }
        }
      }
    },
    "/v1/sales-orders": {
      "post": {
        "summary": "Push sales orders in",
        "description": "An order is PRE-LEDGER: no journal, no control account, no number an auditor will ask about. That is why it can be deleted outright, and why its numbering may gap freely.\n\norder_no is optional. Supply your own and ReconSmith keeps it; omit it and ReconSmith mints one (SO-000001). Both are correct — Gastrac has its own order numbering and should keep it, and a standalone tenant with no upstream system needs ReconSmith to number its own. The same freedom is deliberately NOT given to invoice numbers.\n\nAn order raises no invoice. That happens on exactly two events: payment confirmed, or fulfilment.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "maxItems": 1000,
                "items": {
                  "type": "object",
                  "required": [
                    "external_ref",
                    "lines"
                  ],
                  "properties": {
                    "external_ref": {
                      "type": "string",
                      "description": "Your identifier for the order."
                    },
                    "customer_external_ref": {
                      "type": "string",
                      "description": "Your customer id. Preferred."
                    },
                    "account_code": {
                      "type": "string"
                    },
                    "order_no": {
                      "type": "string",
                      "description": "Yours, or omit it and one is minted."
                    },
                    "order_date": {
                      "type": "string",
                      "format": "date"
                    },
                    "origin": {
                      "type": "string",
                      "enum": [
                        "MANUAL",
                        "TELEMETRY",
                        "ACO",
                        "API",
                        "PORTAL"
                      ],
                      "default": "API",
                      "description": "How the order arrived, so a human can see which were raised by a device."
                    },
                    "reference": {
                      "type": "string"
                    },
                    "notes": {
                      "type": "string"
                    },
                    "source_system": {
                      "type": "string",
                      "default": "gastrac"
                    },
                    "lines": {
                      "type": "array",
                      "minItems": 1,
                      "items": {
                        "type": "object",
                        "required": [
                          "description"
                        ],
                        "properties": {
                          "description": {
                            "type": "string"
                          },
                          "quantity": {
                            "type": "number",
                            "default": 1
                          },
                          "unit_price": {
                            "type": "number"
                          },
                          "tax_amount": {
                            "type": "number"
                          },
                          "account_code": {
                            "type": "string"
                          },
                          "branch_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "site_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "dimensions": {
                            "type": "object",
                            "additionalProperties": {
                              "type": "string"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created; each result carries sales_order_id and order_no"
          }
        }
      },
      "get": {
        "summary": "Pull sales orders out",
        "description": "Orders with their lines, in one call. Filter by status, origin or date.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "DRAFT",
                "OPEN",
                "FULFILLED",
                "CANCELLED",
                "INVOICED"
              ]
            }
          },
          {
            "name": "origin",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "MANUAL",
                "TELEMETRY",
                "ACO",
                "API",
                "PORTAL"
              ]
            }
          },
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 200,
              "maximum": 1000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Orders, each with its lines, invoice_no when invoiced, and invoiced_on_event"
          }
        }
      }
    },
    "/v1/sales-orders/{externalRef}/fulfil": {
      "post": {
        "summary": "Fulfilment — event two of the two that raise an invoice",
        "description": "Gastrac says the order was fulfilled; ReconSmith raises the invoice from the order's own lines and takes the next tax invoice number. Idempotent: an order already invoiced (because it was paid up front) returns the invoice it has rather than a second one.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "name": "externalRef",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "doc_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "due_date": {
                    "type": "string",
                    "format": "date"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "invoice_id, invoice_no, invoiced_on_event"
          },
          "404": {
            "description": "Unknown order"
          }
        }
      }
    },
    "/v1/sales-orders/{externalRef}/payment-confirmation": {
      "post": {
        "summary": "Gateway says paid — event one of the two",
        "description": "The gateway's word, NOT the bank's. Raises the invoice if the order has none, posts the receipt into that gateway's CLEARING account, and allocates it. The bank is untouched: no cash is claimed until the settlement actually lands, so the bank reconciliation is never out by money in transit.\n\nIdempotent on (gateway, gateway_ref): a retried webhook returns the same invoice and receipt rather than paying twice. A later fulfilment of the same order raises nothing new.\n\nThe clearing balance is 'money the gateway owes us'. Anything past the gateway's settlement window shows as overdue in GET /v1/gateway-payments, and that total ties to the clearing account on the trial balance — so the exceptions list cannot quietly disagree with the books.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "name": "externalRef",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "gateway_code",
                  "amount",
                  "gateway_ref"
                ],
                "properties": {
                  "gateway_code": {
                    "type": "string",
                    "description": "e.g. LESAKA"
                  },
                  "amount": {
                    "type": "number",
                    "description": "Gross, as the customer paid it. Fees are handled at settlement."
                  },
                  "gateway_ref": {
                    "type": "string",
                    "description": "The gateway's own transaction id. The idempotency key for this payment."
                  },
                  "confirmed_at": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "invoice_no, receipt_id, status IN_TRANSIT"
          },
          "404": {
            "description": "Unknown order"
          },
          "422": {
            "description": "Unknown or inactive gateway"
          }
        }
      }
    },
    "/v1/sales-orders/{externalRef}": {
      "delete": {
        "summary": "Delete or cancel an un-invoiced order",
        "description": "Shit happens, so an order with no invoice simply goes — lines cascade with it. Nothing downstream ever saw it.\n\nPass cancelOnly=true to keep the row with status CANCELLED instead.\n\nOnce the order has an invoice this refuses: at that point it is part of a document trail, and a credit note is the only correct answer. POST /v1/credit-notes.",
        "parameters": [
          {
            "name": "externalRef",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          },
          {
            "name": "cancelOnly",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "reason",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted or cancelled"
          },
          "404": {
            "description": "Unknown order"
          },
          "422": {
            "description": "The order was invoiced; credit it instead"
          }
        }
      }
    },
    "/v1/gateway-settlements": {
      "post": {
        "summary": "The money landed in the bank, net of the gateway's cut",
        "description": "Dr bank what landed, Dr merchant fees the difference, Cr clearing the gross that was confirmed. Batched, because gateways settle in batches: name the gateway refs in the payout.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "gateway_code",
                  "gateway_refs",
                  "amount_received"
                ],
                "properties": {
                  "gateway_code": {
                    "type": "string"
                  },
                  "gateway_refs": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "amount_received": {
                    "type": "number",
                    "description": "Net, as it appears on the bank statement. The difference from the gross becomes the fee."
                  },
                  "bank_account_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "settled_on": {
                    "type": "string",
                    "format": "date"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "journal_id, gross, fee, payments_settled"
          },
          "422": {
            "description": "Refs not in transit, or a settlement larger than the gross confirmed"
          }
        }
      }
    },
    "/v1/gateway-payments": {
      "get": {
        "summary": "In transit — the list that needs handling",
        "description": "Confirmed by a gateway, not yet in the bank. overdue is anything past that gateway's own settlement window (7 days by default). The total ties to the clearing account on the trial balance.",
        "parameters": [
          {
            "name": "overdueOnly",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "total_in_transit, overdue_count, and the payments with days_in_transit"
          }
        }
      }
    },
    "/v1/customers/{externalRef}/credit": {
      "get": {
        "summary": "Customer credit — what the app shows",
        "description": "Credit the customer can spend on the next order, how much of it they asked to have refunded instead, and the documents behind the number so the app can say where each credit came from. Gastrac uses this to apply a credit to the next order rather than refunding it.",
        "parameters": [
          {
            "name": "externalRef",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "credit_available, marked_for_refund, spendable_on_next_order, documents"
          },
          "404": {
            "description": "Unknown customer"
          }
        }
      }
    },
    "/v1/documents/{externalRef}/pdf": {
      "get": {
        "summary": "The issued document as a PDF",
        "description": "The tax invoice or credit note on the ReconSmith letterhead, exactly as posted: our invoice number, the date and due date, our company details and VAT number as the letterhead stood on that date, the customer's name, address and VAT number, every line, the totals, and the reference (your order or work-order number).\n\nFetch it once when the invoice is posted and store it; fetch again if a credit note is raised against it. Your app then serves its stored copy, so a customer can still get their invoice when ReconSmith is slow or unreachable.\n\nDrawn by one renderer from the template's render_spec, and reissued on the branding of the document's own date — a copy fetched next year matches the one that was sent. The service does not draw PDFs itself: a second implementation would mean two versions of the same tax invoice, with no way to say which one the customer received.\n\nOnly a POSTED document has an issued PDF; anything else is a 409.",
        "parameters": [
          {
            "name": "externalRef",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Your identifier for the document — the order number you invoiced under."
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "application/pdf, named for our document number, e.g. \"Tax invoice INV-00041.pdf\"",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Unknown document"
          },
          "409": {
            "description": "The document is not POSTED, so no PDF was ever issued"
          },
          "502": {
            "description": "The renderer is unreachable or refused"
          },
          "503": {
            "description": "No renderer is configured"
          }
        }
      }
    },
    "/v1/documents/{externalRef}/content": {
      "get": {
        "summary": "The issued document as JSON or XML",
        "description": "The same document as data: our invoice number, date and due date; our company details and VAT number from the letterhead for that date; the customer's name, address and VAT number; every line with description, quantity, unit price, amount excluding VAT and VAT; the totals, a tax summary by rate, and the reference.\n\nBuilt from the ledger rather than from what the caller pushed, so if your copy and ours ever disagree, ours is right by construction. Not needed when you store the PDF, but it is what you want to render the invoice natively in an app or to keep an archival XML copy. Bank account number is masked to the last four: the full number belongs on the PDF the supplier issues, not in a payload that gets cached.\n\nformat=xml, or an Accept header asking for XML, returns the XML rendering of the same facts.",
        "parameters": [
          {
            "name": "externalRef",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "json",
                "xml"
              ],
              "default": "json"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The document",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              },
              "application/xml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "Unknown document"
          }
        }
      }
    },
    "/v1/credits": {
      "get": {
        "summary": "Unused customer credit",
        "description": "Every credit still unused, for customers the caller knows. Scoped by the CUSTOMER's external reference, not the document's: a credit note a bookkeeper typed into the office for a Gastrac customer is that customer's credit, and the app must show it alongside ones Gastrac pushed. raised_via says which screen it came from (OFFICE, GASTRAC, …).\\n\\nA credit note that was applied to an invoice has done its job and does not appear.\\n\\nEach row carries document_id and doc_no as well as external_ref, so an office-raised credit with no external_ref can still be addressed by the refund endpoints.\\n\\nPer customer, use GET /v1/customers/{ref}/credit instead.",
        "parameters": [
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          },
          {
            "name": "refundOnly",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Only credits the customer has asked to have refunded."
          },
          {
            "name": "knownOnly",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": true
            },
            "description": "Default true: only customers that have an external reference in this source system. false returns every open credit, including customers you have never pushed."
          }
        ],
        "responses": {
          "200": {
            "description": "total_unused, total_refund_requested, and the credits with unused, refund_requested, refund_requested_at, refund_note and raised_via"
          }
        }
      }
    },
    "/v1/credit-notes/{externalRef}/refund-request": {
      "post": {
        "summary": "The customer asks for the money back",
        "description": "Flags an existing credit note as refund-requested. It is a flag, not a payment: nothing moves in the ledger and the customer can change their mind right up until the money leaves. Refused if the credit has already been used up.\\n\\nThe flag shows as refund_requested in GET /v1/credits, and refundOnly=true filters to these.",
        "parameters": [
          {
            "name": "externalRef",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Your own external_ref, our document number (CRN-00004) or our document id. GET /v1/credits returns all three, so an office-raised credit with no external_ref is still addressable."
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "note": {
                    "type": "string",
                    "description": "Why, or how they asked. Shown beside the credit."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "doc_no, unused, requested_at"
          },
          "404": {
            "description": "No such credit note"
          },
          "422": {
            "description": "Not a posted credit note, or nothing left to refund"
          }
        }
      },
      "delete": {
        "summary": "They changed their mind",
        "description": "Clears the flag. The credit itself is untouched and stays on the account.",
        "parameters": [
          {
            "name": "externalRef",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Your own external_ref, our document number (CRN-00004) or our document id. GET /v1/credits returns all three, so an office-raised credit with no external_ref is still addressable."
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "refund_requested is now false"
          },
          "404": {
            "description": "No such credit note"
          }
        }
      }
    },
    "/v1/credit-notes/{externalRef}/refund": {
      "post": {
        "summary": "Pay a credit back in money",
        "description": "The money leaves: Dr trade receivables, Cr bank. The refund is a settleable document (CUSTOMER_REFUND, its own REF- sequence) and the credit note is allocated to it, exactly as a receipt settles an invoice — so over-refunding is refused by the allocation rather than by trusting the caller.\\n\\nOmit amount to refund whatever is unused. A partial refund leaves the rest on the customer's account, which is a thing customers ask for. Omit bank_account_id and the entity's primary bank account is used.\\n\\nTakes an Idempotency-Key like every write: a retried refund must not pay the customer twice. Once credit_left reaches zero the credit drops off GET /v1/credits.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "name": "externalRef",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Your own external_ref, our document number (CRN-00004) or our document id. GET /v1/credits returns all three, so an office-raised credit with no external_ref is still addressable."
          },
          {
            "name": "sourceSystem",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "gastrac"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "amount": {
                    "type": "number",
                    "description": "Omit to refund the full unused balance."
                  },
                  "bank_account_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "doc_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "reference": {
                    "type": "string",
                    "description": "Yours — the EFT reference, say."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "refund_no, amount, credit_left"
          },
          "404": {
            "description": "No such credit note"
          },
          "422": {
            "description": "Nothing left to refund, more than is unused, or a closed period"
          }
        }
      }
    }
  }
}