{
  "openapi": "3.0.3",
  "info": {
    "title": "loc-sim API",
    "description": "Workspace/scenario API for the LOC Tiered FR simulator. A **key** (created via `POST /api/keys`) names a private workspace; every other workspace endpoint requires it — as the `key` query parameter or the `X-Api-Key` header. Scenarios are stored as the exact model JSON the `/to-be` page builds; all money math runs in the browser, the server stores only inputs. Open `/to-be?key=…` to drive the same workspace from the UI. (info.version is injected from server/package.json at serve time.)",
    "version": "1.9.0"
  },
  "servers": [
    {
      "url": "https://loc-simulator.sandbox.orgmeter.com",
      "description": "stage"
    },
    {
      "url": "http://localhost:8080",
      "description": "local"
    }
  ],
  "tags": [
    {
      "name": "service",
      "description": "Health and wiring checks (no key required)"
    },
    {
      "name": "keys",
      "description": "Workspace keys"
    },
    {
      "name": "scenarios",
      "description": "Scenario documents in the key's workspace"
    },
    {
      "name": "melio-sim",
      "description": "OM/Melio-shaped seed: real-calendar advance → future expected payback payments"
    }
  ],
  "components": {
    "securitySchemes": {
      "KeyQuery": {
        "type": "apiKey",
        "in": "query",
        "name": "key"
      },
      "KeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key"
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          }
        },
        "required": [
          "error"
        ],
        "example": {
          "error": "invalid key"
        }
      },
      "HealthCheck": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "example": "mysql"
          },
          "status": {
            "type": "string",
            "enum": [
              "healthy",
              "unhealthy"
            ]
          }
        }
      },
      "Health": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "healthy",
              "degraded"
            ]
          },
          "version": {
            "type": "string",
            "example": "4.8",
            "description": "Simulator (handoff package.json) version"
          },
          "uptimeSeconds": {
            "type": "integer"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "checks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/HealthCheck"
            }
          }
        }
      },
      "TestEcho": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "method": {
            "type": "string"
          },
          "path": {
            "type": "string"
          },
          "query": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Key": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string",
            "example": "ls_8EdiOoGxf1lkOvMBrRkWI9SKVQkGvRWw",
            "description": "Returned once; the server stores only a SHA-256 hash"
          }
        }
      },
      "WorkspaceInfo": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "scenarios": {
            "type": "integer",
            "description": "Number of scenarios in the workspace"
          }
        }
      },
      "Model": {
        "type": "object",
        "description": "The exact model document the /to-be page builds: `account` config, `draws`, `events`, `_eid` (next event id). The browser engine computes everything from these inputs; the server never re-computes money figures.",
        "required": [
          "account",
          "draws",
          "events"
        ],
        "properties": {
          "account": {
            "type": "object",
            "description": "Account config (termDays, collectionWeekday, monthlyFee {percent, delayDays}, funderPaymentDelay, tieredFactorRate {enabled}, holds[], …)"
          },
          "draws": {
            "type": "array",
            "maxItems": 200,
            "items": {
              "type": "object",
              "description": "{activationDay, requestedAmount, estFeePct, factorRate, per-draw funder overrides…}"
            }
          },
          "events": {
            "type": "array",
            "maxItems": 2000,
            "items": {
              "type": "object",
              "description": "{id, day, type: voluntary|payoff|bounce|skip|regular|fee, amount, drawNum, feeType}"
            }
          },
          "_eid": {
            "type": "integer",
            "description": "Next event id; re-derived from max(event id)+1 on load if missing"
          }
        }
      },
      "ScenarioDocument": {
        "type": "object",
        "required": [
          "name",
          "model"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 200,
            "example": "Standard draw — 100k @ 1% fee"
          },
          "model": {
            "$ref": "#/components/schemas/Model"
          }
        }
      },
      "ScenarioSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "draws": {
            "type": "integer"
          },
          "events": {
            "type": "integer"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Scenario": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "model": {
            "$ref": "#/components/schemas/Model"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Created": {
        "type": "object",
        "properties": {
          "created": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "integer"
                },
                "name": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "MelioPrepayment": {
        "type": "object",
        "required": [
          "amount",
          "date"
        ],
        "properties": {
          "amount": {
            "type": "number",
            "description": "Voluntary payback amount"
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Due On date; must be within dateFunded + 30 calendar days (Tier-1 window)"
          }
        }
      },
      "MelioDraw": {
        "type": "object",
        "required": [
          "dateFunded",
          "requestedAmount"
        ],
        "properties": {
          "dateFunded": {
            "type": "string",
            "format": "date",
            "description": "Real funded date, must be <= today"
          },
          "requestedAmount": {
            "type": "number"
          },
          "paidPayments": {
            "type": "array",
            "items": {
              "type": "integer",
              "minimum": 1
            },
            "description": "Which past scheduled paybacks were actually paid; a missing past number means bounced; future numbers are ignored; field absent = all past payments paid"
          },
          "prepayment": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MelioPrepayment"
            }
          }
        }
      },
      "MelioSeedRequest": {
        "type": "object",
        "required": [
          "advanceId",
          "funderDelay",
          "draw"
        ],
        "properties": {
          "advanceId": {
            "type": "string",
            "maxLength": 64,
            "example": "NC12345",
            "description": "Unique advance id; persisted (scenarios.advance_id) — re-seeding the same id in the same workspace replaces the scenario"
          },
          "funderDelay": {
            "type": "integer",
            "enum": [
              0,
              -1
            ],
            "description": "0 = paybacks start next weekday, -1 = same day"
          },
          "draw": {
            "type": "array",
            "minItems": 1,
            "maxItems": 20,
            "items": {
              "$ref": "#/components/schemas/MelioDraw"
            }
          },
          "monthlyFeePercent": {
            "type": "number",
            "minimum": 0,
            "maximum": 100,
            "default": 0,
            "description": "Optional. Monthly fee as % of each draw's FUNDED amount, charged every 30 calendar days after monthlyFeeDelay. 0 (default) = no fee. E.g. 0.25 = 0.25%."
          },
          "monthlyFeeDelay": {
            "type": "integer",
            "minimum": 0,
            "default": 0,
            "description": "Optional. Calendar days from the Paybacks Start Date before the monthly fee starts. Default 0."
          }
        },
        "description": "Fixed account settings apply to every seed and are not overridable: Term = 150 calendar days, Est. fee % = 0 (funded = requested), Credit limit = 0. The schema deliberately has no fields for them."
      },
      "RegisterPayment": {
        "type": "object",
        "description": "One row of the register. Field names, types and the string-typed amounts follow production verbatim.",
        "properties": {
          "guid": {
            "type": "string",
            "description": "Derived, not modelled: a deterministic UUID-shaped hash, stable across identical calls.",
            "example": "1e5e1dda-349b-54c5-a484-55a67a9e6eb9"
          },
          "dueAt": {
            "type": "string",
            "format": "date",
            "description": "The transaction's own day, mapped to a real date. In the simulator the due date and the creation date coincide, because every transaction happens on its own day.",
            "example": "2026-08-28"
          },
          "amount": {
            "type": "string",
            "description": "Signed, as production: positive only for the disbursement (money reaching the merchant), negative for every collection and fee.",
            "example": "-6405.00"
          },
          "paymentType": {
            "type": "string",
            "description": "The production LABEL, not an enum code. **`\"Default Fee\"` WILL NOT BE RETURNED BY ORGMETER**: a LOC Default Fee arrives there as an `other_fee` payment and the merchant register renders it as **\"Other Fee\"** (only Weekly / Monthly / Termination fees are special-cased). Every other label is production’s own.",
            "example": "Account Payback",
            "enum": [
              "Draw",
              "Account Payback",
              "Account Voluntary Payback",
              "Account Draw Payoff Payback",
              "Monthly Fee",
              "Weekly Fee",
              "Default Fee"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "paid",
              "bounced",
              "ignored",
              "unpaid"
            ],
            "description": "unpaid appears for a Default Fee the pool has not retired, and for a not-yet-due row when includeScheduled=true. ignored is never emitted. **`\"unpaid\"` on a payback row WILL NOT APPEAR IN ORGMETER** — it is reachable here only through `includeScheduled`, and production has no future rows."
          },
          "achStatus": {
            "type": "string",
            "nullable": true,
            "enum": [
              "awaiting_approval",
              "scheduled",
              "skipped",
              "ignored",
              "submitted",
              "pre_pending",
              "pending",
              "settled",
              "bounced",
              "errored",
              "unsuccessful",
              "voided",
              "deleted",
              "rejected",
              "pre_scheduled"
            ],
            "description": "Derived, not modelled. Always null for a Default Fee. **`\"pre_scheduled\"` WILL NOT APPEAR IN ORGMETER’s register** — same reason: it is only ever set on a not-yet-due row, which production does not carry."
          },
          "processingMode": {
            "type": "string",
            "enum": [
              "manual",
              "ach"
            ],
            "description": "Derived, not modelled. manual for a Default Fee, ach elsewhere."
          },
          "fundedAmount": {
            "type": "string",
            "nullable": true,
            "description": "The principal (funded) part of that payback - ORG-18013: the payment divided by the tier factor rate at its own due day, or by the week's averaged rate across a tier boundary. Gated by payment TYPE exactly as production gates it with isPayback(): a payback row always carries the field, \"0.00\" when nothing was repaid, while Draw, Monthly Fee and Default Fee carry null.",
            "example": "5833.33"
          }
        }
      },
      "ExpectedPaybackPayment": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "account_payback",
              "monthly_fee"
            ],
            "description": "Which kind of payment this row is. `account_payback` — the draws' scheduled collection. `monthly_fee` — one draw's monthly fee, on its own charge date; each draw charges its own, so two draws funded the same day give two rows on that date. On a shared date the fee row comes first."
          },
          "dueAt": {
            "type": "string",
            "format": "date",
            "description": "Real future payment date (weekday-preserving mapping from the simulator axis)"
          },
          "amount": {
            "type": "number",
            "description": "The payment due on that date. For `account_payback` it is the draws' scheduled collection; for `monthly_fee` it is ONE draw's monthly charge. **Changed 2026-09-02:** an `account_payback` amount no longer silently contains fee money — the fee has its own row — so a caller that debits `amount` must process `monthly_fee` rows too or it will under-collect by the fee."
          },
          "totalOutstandingAmount": {
            "type": "number",
            "description": "The whole remaining bill on the account after this payment — the draws' outstanding payback plus every monthly fee still to come. It falls by each row's `amount`, whichever type the row is, and reaches 0 on the last row."
          },
          "totalOutstandingPayoffAmount": {
            "type": "number",
            "description": "What it costs to close the advance on that date after this payment — the EPD-discounted payoff plus every monthly fee still to come. It falls by each payment, and is additionally RE-MARKED upward when the draw crosses an EPD tier, so it is not monotonic by design."
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid key",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Scenario not found in this workspace",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "Method not allowed",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "DbUnavailable": {
        "description": "Database not configured on this instance",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  },
  "paths": {
    "/api/health": {
      "get": {
        "tags": [
          "service"
        ],
        "summary": "Health report",
        "responses": {
          "200": {
            "description": "All checks healthy",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          },
          "503": {
            "description": "Degraded — one or more checks unhealthy",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          }
        }
      }
    },
    "/api/test": {
      "get": {
        "tags": [
          "service"
        ],
        "summary": "Echo endpoint for wiring checks",
        "parameters": [
          {
            "name": "anything",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "All query params are echoed back"
          }
        ],
        "responses": {
          "200": {
            "description": "Echo",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TestEcho"
                }
              }
            }
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          }
        }
      }
    },
    "/api/keys": {
      "post": {
        "tags": [
          "keys"
        ],
        "summary": "Create a key (= a private workspace)",
        "description": "No auth. Rate-limited to 10 keys per hour per IP. The key is returned once and stored only as a SHA-256 hash.",
        "responses": {
          "201": {
            "description": "Key created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Key"
                }
              }
            }
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      }
    },
    "/api/keys/current": {
      "get": {
        "tags": [
          "keys"
        ],
        "summary": "Validate the key, count its scenarios",
        "security": [
          {
            "KeyQuery": []
          },
          {
            "KeyHeader": []
          }
        ],
        "responses": {
          "200": {
            "description": "Key is valid",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkspaceInfo"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      }
    },
    "/api/scenarios": {
      "get": {
        "tags": [
          "scenarios"
        ],
        "summary": "List the workspace's scenarios",
        "security": [
          {
            "KeyQuery": []
          },
          {
            "KeyHeader": []
          }
        ],
        "responses": {
          "200": {
            "description": "Scenario summaries",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "scenarios": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ScenarioSummary"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      },
      "post": {
        "tags": [
          "scenarios"
        ],
        "summary": "Seed scenarios into the workspace",
        "description": "Body is one `{name, model}` document, an **array** of them (bulk seed), or `{\"starterPack\": true}` to create three starter scenarios built through the real simulator model module. Max 200 scenarios per workspace; body capped at 512 KB.",
        "security": [
          {
            "KeyQuery": []
          },
          {
            "KeyHeader": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/ScenarioDocument"
                  },
                  {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/ScenarioDocument"
                    }
                  },
                  {
                    "type": "object",
                    "properties": {
                      "starterPack": {
                        "type": "boolean",
                        "enum": [
                          true
                        ]
                      }
                    },
                    "required": [
                      "starterPack"
                    ]
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Created"
                }
              }
            }
          },
          "400": {
            "description": "Structurally invalid document / empty body / workspace limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "413": {
            "description": "Body larger than 512 KB",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      }
    },
    "/api/scenarios/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer"
          }
        }
      ],
      "get": {
        "tags": [
          "scenarios"
        ],
        "summary": "Fetch the full scenario document",
        "security": [
          {
            "KeyQuery": []
          },
          {
            "KeyHeader": []
          }
        ],
        "responses": {
          "200": {
            "description": "The document the /to-be page loads",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Scenario"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      },
      "put": {
        "tags": [
          "scenarios"
        ],
        "summary": "Rename and/or replace a scenario",
        "security": [
          {
            "KeyQuery": []
          },
          {
            "KeyHeader": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 200
                  },
                  "model": {
                    "$ref": "#/components/schemas/Model"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Neither name nor model given / invalid values",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      },
      "delete": {
        "tags": [
          "scenarios"
        ],
        "summary": "Delete a scenario",
        "security": [
          {
            "KeyQuery": []
          },
          {
            "KeyHeader": []
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      }
    },
    "/orgmeter/melio_sim/payment/": {
      "get": {
        "tags": [
          "melio-sim"
        ],
        "summary": "The payment register of an advance already in this workspace",
        "description": "The merchant's payment register - OrgMeter's merchant-portal `paymentIndex` (GET /api/loc-merchant-portal/v1/payment/), simulated.\n\nWhere the sibling endpoint answers what WILL be collected, this one answers what WAS: every payment with its status, sign, label and principal split, newest first, with production's own `filter[...]` names and its header-only pagination.\n\n**Rows come from one pass over the simulator ledger** - nothing is recomputed, so no figure here can disagree with the /to-be page or with the expected-payback endpoint. Collections are aggregated to the ACCOUNT (production's merchant register admits only the account-level payback), while each draw's Monthly Fee stays its own row, as production files it.\n\n**Three fields are DERIVED, not modelled.** The simulator has no ACH and mints no identifiers: `guid` is a deterministic UUID-shaped hash of (advanceId, dueAt, paymentType, sequence), and `achStatus`/`processingMode` follow a fixed rule set. In production those two come from two INDEPENDENT facts - `achStatus` is LOC-core's ACH state machine, while `processingMode` is org's `ach_connected`, itself `achProcessed && locked` - and they can disagree there. Do not read this endpoint as evidence that one is a function of the other.\n\n`ignored` is never emitted; it stays in the enum because production has it.\n\nGET reads the stored model. An advance seeded only by the expected-payback endpoint carries no calendar anchor - pass `anchorDate`, or re-seed through POST.\n---\n\n### What will NOT be available in OrgMeter\n\nEverything below exists in this simulator and has **no counterpart in the real merchant portal**.\nA client written against these must not assume OrgMeter will accept or return them.\n\n1. **`POST /payment/` — THIS API WILL NOT BE AVAILABLE IN ORGMETER.** There `/payment/` is GET-only;\n   this route exists because the simulator has no real accounts, so an advance must be described\n   before it can be listed.\n2. **`advanceId`, `anchorDate`, `includeScheduled` — THESE PARAMETERS WILL NOT BE AVAILABLE IN\n   ORGMETER.** There the advance comes from the JWT rather than a query parameter, the model needs\n   no calendar anchor because its dates are real, and the register has no not-yet-due rows at all\n   (OrgMeter projects those through its separate expected-payback endpoint).\n3. **`paymentType: \"Default Fee\"` — THIS VALUE WILL NOT BE RETURNED BY ORGMETER.** A LOC Default Fee\n   reaches OrgMeter as an `other_fee` payment, and the merchant register special-cases only\n   Weekly / Monthly / Termination fees, so it renders there as **\"Other Fee\"**. Every other label\n   in the enum is production’s own.\n4. **`status: \"unpaid\"` on a payback row, and `achStatus: \"pre_scheduled\"` — THESE WILL NOT APPEAR\n   IN ORGMETER’s register.** Both values exist in production’s enums, but they are reachable here\n   only through `includeScheduled`, and OrgMeter has no future rows to carry them.",
        "security": [
          {
            "KeyQuery": []
          },
          {
            "KeyHeader": []
          }
        ],
        "parameters": [
          {
            "name": "advanceId",
            "in": "query",
            "description": "**THIS PARAMETER WILL NOT BE AVAILABLE IN ORGMETER** — there the advance comes from the JWT, not from the query string. The advance to list. Required on GET.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "anchorDate",
            "in": "query",
            "description": "**THIS PARAMETER WILL NOT BE AVAILABLE IN ORGMETER** — simulator-only: the model lives on a relative day axis and needs an anchor, production’s dates are already real. Calendar anchor (the oldest draw's funded date) for a scenario stored without one - an advance seeded by the expected-payback endpoint, or a scenario built in the /to-be UI. Its ISO weekday must match the first draw's simulator day, or the whole schedule would render on the wrong weekdays.",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "includeScheduled",
            "in": "query",
            "description": "**THIS PARAMETER WILL NOT BE AVAILABLE IN ORGMETER** — its register has no not-yet-due rows at all. Simulator-only. Append the rows that are not yet due, always status=unpaid. Production has no rows there yet, so the default is false and the response is the record.",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "page",
            "in": "query",
            "description": "Page, 1-based.",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 1,
              "minimum": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "description": "Rows per page. Clamped to at least 1: production computes abs((int) pageSize) and then divides by it, so pageSize=0 raises a DivisionByZeroError there. The simulator does not reproduce a crash on caller input - the only place it knowingly behaves better than production.",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 100,
              "minimum": 1
            }
          },
          {
            "name": "filter[guid]",
            "in": "query",
            "description": "Exact match on the row guid.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[status]",
            "in": "query",
            "description": "Exact match: paid | bounced | ignored | unpaid.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[paymentType]",
            "in": "query",
            "description": "Exact match on the LABEL, as production does: Draw, Account Payback, Account Voluntary Payback, Account Draw Payoff Payback, Monthly Fee, Weekly Fee, Default Fee.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[amount]",
            "in": "query",
            "description": "Exact match on the SIGNED amount the response carries. Production filters its stored unsigned column instead; the simulator filters what the caller can see.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[dueAt]",
            "in": "query",
            "description": "A single day, YYYY-MM-DD.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[fromDueAt]",
            "in": "query",
            "description": "Inclusive lower bound on dueAt.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[toDueAt]",
            "in": "query",
            "description": "Inclusive upper bound on dueAt.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[search]",
            "in": "query",
            "description": "Case-insensitive contains over guid, amount and status.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[_sort_guid]",
            "in": "query",
            "description": "asc or desc. The first sort parameter present wins; the default order is dueAt desc, as production.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "filter[_sort_status]",
            "in": "query",
            "description": "asc or desc. The first sort parameter present wins; the default order is dueAt desc, as production.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "filter[_sort_dueAt]",
            "in": "query",
            "description": "asc or desc. The first sort parameter present wins; the default order is dueAt desc, as production.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "filter[_sort_amount]",
            "in": "query",
            "description": "asc or desc. The first sort parameter present wins; the default order is dueAt desc, as production.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The register: newest first by dueAt.",
            "headers": {
              "X-Page": {
                "description": "Page returned.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-Limit": {
                "description": "Rows per page.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-Max-Page": {
                "description": "ceil(count / pageSize) - production's own formula, so an empty result is 0, not 1.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-Count": {
                "description": "Total rows after filtering, before paging.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RegisterPayment"
                  }
                }
              }
            }
          },
          "400": {
            "description": "advanceId missing, or anchorDate not YYYY-MM-DD.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No such advance in this workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "409": {
            "description": "The advance has no calendar anchor, the anchor lands on the wrong weekday, or the stored model cannot be replayed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      },
      "post": {
        "tags": [
          "melio-sim"
        ],
        "summary": "Seed an advance from real calendar dates and answer its payment register",
        "description": "The merchant's payment register - OrgMeter's merchant-portal `paymentIndex` (GET /api/loc-merchant-portal/v1/payment/), simulated.\n\nWhere the sibling endpoint answers what WILL be collected, this one answers what WAS: every payment with its status, sign, label and principal split, newest first, with production's own `filter[...]` names and its header-only pagination.\n\n**Rows come from one pass over the simulator ledger** - nothing is recomputed, so no figure here can disagree with the /to-be page or with the expected-payback endpoint. Collections are aggregated to the ACCOUNT (production's merchant register admits only the account-level payback), while each draw's Monthly Fee stays its own row, as production files it.\n\n**Three fields are DERIVED, not modelled.** The simulator has no ACH and mints no identifiers: `guid` is a deterministic UUID-shaped hash of (advanceId, dueAt, paymentType, sequence), and `achStatus`/`processingMode` follow a fixed rule set. In production those two come from two INDEPENDENT facts - `achStatus` is LOC-core's ACH state machine, while `processingMode` is org's `ach_connected`, itself `achProcessed && locked` - and they can disagree there. Do not read this endpoint as evidence that one is a function of the other.\n\n`ignored` is never emitted; it stays in the enum because production has it.\n\nPOST takes the SAME body as the expected-payback endpoint, so one document describes an advance for both and the two can never seed it differently. The built model is upserted as scenario \"Melio <advanceId>\" together with its calendar anchor, in one write - a retry converges on one scenario rather than duplicating the advance.\n---\n\n### What will NOT be available in OrgMeter\n\nEverything below exists in this simulator and has **no counterpart in the real merchant portal**.\nA client written against these must not assume OrgMeter will accept or return them.\n\n1. **`POST /payment/` — THIS API WILL NOT BE AVAILABLE IN ORGMETER.** There `/payment/` is GET-only;\n   this route exists because the simulator has no real accounts, so an advance must be described\n   before it can be listed.\n2. **`advanceId`, `anchorDate`, `includeScheduled` — THESE PARAMETERS WILL NOT BE AVAILABLE IN\n   ORGMETER.** There the advance comes from the JWT rather than a query parameter, the model needs\n   no calendar anchor because its dates are real, and the register has no not-yet-due rows at all\n   (OrgMeter projects those through its separate expected-payback endpoint).\n3. **`paymentType: \"Default Fee\"` — THIS VALUE WILL NOT BE RETURNED BY ORGMETER.** A LOC Default Fee\n   reaches OrgMeter as an `other_fee` payment, and the merchant register special-cases only\n   Weekly / Monthly / Termination fees, so it renders there as **\"Other Fee\"**. Every other label\n   in the enum is production’s own.\n4. **`status: \"unpaid\"` on a payback row, and `achStatus: \"pre_scheduled\"` — THESE WILL NOT APPEAR\n   IN ORGMETER’s register.** Both values exist in production’s enums, but they are reachable here\n   only through `includeScheduled`, and OrgMeter has no future rows to carry them.",
        "security": [
          {
            "KeyQuery": []
          },
          {
            "KeyHeader": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MelioSeedRequest"
              }
            }
          }
        },
        "parameters": [
          {
            "name": "includeScheduled",
            "in": "query",
            "description": "**THIS PARAMETER WILL NOT BE AVAILABLE IN ORGMETER** — its register has no not-yet-due rows at all. Simulator-only. Append the rows that are not yet due, always status=unpaid. Production has no rows there yet, so the default is false and the response is the record.",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "page",
            "in": "query",
            "description": "Page, 1-based.",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 1,
              "minimum": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "description": "Rows per page. Clamped to at least 1: production computes abs((int) pageSize) and then divides by it, so pageSize=0 raises a DivisionByZeroError there. The simulator does not reproduce a crash on caller input - the only place it knowingly behaves better than production.",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 100,
              "minimum": 1
            }
          },
          {
            "name": "filter[guid]",
            "in": "query",
            "description": "Exact match on the row guid.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[status]",
            "in": "query",
            "description": "Exact match: paid | bounced | ignored | unpaid.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[paymentType]",
            "in": "query",
            "description": "Exact match on the LABEL, as production does: Draw, Account Payback, Account Voluntary Payback, Account Draw Payoff Payback, Monthly Fee, Weekly Fee, Default Fee.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[amount]",
            "in": "query",
            "description": "Exact match on the SIGNED amount the response carries. Production filters its stored unsigned column instead; the simulator filters what the caller can see.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[dueAt]",
            "in": "query",
            "description": "A single day, YYYY-MM-DD.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[fromDueAt]",
            "in": "query",
            "description": "Inclusive lower bound on dueAt.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[toDueAt]",
            "in": "query",
            "description": "Inclusive upper bound on dueAt.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[search]",
            "in": "query",
            "description": "Case-insensitive contains over guid, amount and status.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter[_sort_guid]",
            "in": "query",
            "description": "asc or desc. The first sort parameter present wins; the default order is dueAt desc, as production.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "filter[_sort_status]",
            "in": "query",
            "description": "asc or desc. The first sort parameter present wins; the default order is dueAt desc, as production.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "filter[_sort_dueAt]",
            "in": "query",
            "description": "asc or desc. The first sort parameter present wins; the default order is dueAt desc, as production.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "filter[_sort_amount]",
            "in": "query",
            "description": "asc or desc. The first sort parameter present wins; the default order is dueAt desc, as production.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The register: newest first by dueAt.",
            "headers": {
              "X-Page": {
                "description": "Page returned.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-Limit": {
                "description": "Rows per page.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-Max-Page": {
                "description": "ceil(count / pageSize) - production's own formula, so an empty result is 0, not 1.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-Count": {
                "description": "Total rows after filtering, before paging.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RegisterPayment"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation failure - the same rules the expected-payback endpoint applies.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "413": {
            "description": "Body larger than 512 KB",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      }
    },
    "/orgmeter/melio_sim/payment/expected-payback-payment": {
      "post": {
        "tags": [
          "melio-sim"
        ],
        "summary": "Seed an advance from real calendar dates; get its future expected payback payments",
        "description": "Maps real dates onto the simulator day axis (oldest draw lands on the day matching its real ISO weekday; everything shifts by the calendar difference; the reverse transform produces response dates). paidPayments gaps become bounces on the exact due days (per-day, account-wide granularity); prepayments become Tier-1 voluntary paybacks and must be dated on or before today. The built model is upserted into the key's workspace as scenario \"Melio <advanceId>\" — open /to-be?key=… to inspect it. All money math runs in the simulator engine.\n\n**The payment list is typed.** Each element is either an `account_payback` — the draws' scheduled collection, the draw's money only — or a `monthly_fee`, ONE draw's monthly charge on its own charge date. Each draw charges its own fee, so two draws funded the same day give two fee rows on that date, and charge dates are NOT moved onto the collection grid. On a shared date the fee row comes first. **A caller that debits `amount` must process `monthly_fee` rows too, or it under-collects by the fee.**\n\nThe first row's totals carry the whole remaining bill — the draws' outstanding plus every future monthly fee — and each row steps both totals down by its own amount until they reach 0 on the last row. `totalOutstandingPayoffAmount` is additionally re-marked upward when a draw crosses an EPD tier, so it is not monotonic by design.",
        "security": [
          {
            "KeyQuery": []
          },
          {
            "KeyHeader": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MelioSeedRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Future payments, strictly after today, ascending by date",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ExpectedPaybackPayment"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation failure (future dateFunded, prepayment beyond the 30-day window, bad funderDelay, …)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "413": {
            "description": "Body larger than 512 KB",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/DbUnavailable"
          }
        }
      }
    }
  }
}