Inspiration

Solo sellers, freelancers, and micro-agencies live in PayPal but drown in tabs: invoicing in one place, checkout links in another, contractor payouts and dispute deadlines in their head. Existing tools record money; nothing operates it for them. LedgerPilot is the operator: you say what happened in plain English, and the money work is drafted, approved, executed, and reconciled for you — with a paper trail for every step.

What it does

You give LedgerPilot a brief:

"Invoice Ana $450 for brand work, due Friday. When it lands, pay $200 to my contractor Marco."

LedgerPilot turns that brief into a typed action plan (create_invoice / create_order / draft_payout / send_reminder) showing the exact PayPal calls it will make, and executes only after you approve. Money in (Invoicing v2 invoices, Orders v2 checkout), money out (Payouts batches, released only on approval), and every step — plan, approval, PayPal call, confirming webhook — lands in an AG Grid operations console: six grids (Invoices, Orders/Checkout, Payouts, Disputes, Reconciliation / Transaction Search, and the Agent Activity & Webhook Audit trail) with a KPI strip, CSV export, and rows that flash when a webhook flips their state.

Highlights:

  • Approval gate (the trust invariant). Nothing executes without a plan + an explicit approval record — on every surface, including the REST draft wrappers (POST /api/paypal/invoices|orders|payouts draft plans instead of calling PayPal). Trigger-gated steps (a payout that waits for INVOICING.INVOICE.PAID) resume only via a signature-verified webhook or the sync reconciler, and only for the invoice they are bound to.
  • Plan amendment (Approve / Edit / Reject, complete). A reviewer who spots a wrong amount no longer rejects and re-drafts from scratch: POST /api/agent/edit amends the amount/currency of a pending plan's money steps (a batch payout step per line) — only while the plan is in pending_approval, so approval always covers exactly the steps the reviewer saw; every amount is validated through the resource drafts' own money rules (positive, ≤ 100,000, USD/EUR/GBP); each amended step's description is regenerated in its builder's exact format; the amendment (old → new per step) is written to the audit log; the plan's brief is never rewritten; and a no-change edit is refused. Recipients, work descriptions, due dates, and triggers are not editable — changing who gets paid or what for is a new plan. The console plan review renders the amount inputs and the Amend amounts action alongside Approve / Reject, only while pending.
  • Next-action suggestions (the agent drafts its own follow-ups). CONCEPT.md §3.1's data flow ends with the agent drafting the next action as a new pending plan — POST /api/agent/suggest (and the console's Suggest next actions button) is that step: it scans for overdue open invoices (the console read model's own definition, most overdue first) and drafts one send_reminder plan per uncovered invoice (source: "suggested", pending_approval, its reference pinned to that invoice's number, the step identical to a hand-drafted reminder). An invoice whose reminder was already sent, or that a reminder plan in any status other than failed already targets, is reported under skipped with its reason — a rejected plan is not re-drafted, a failed plan (whose reminder never went out) is the one exception, and nothing is ever sent without the ordinary approve → execute flow.
  • Deterministic-first AI. The default brain is a rule/parser engine built for this project — no API key, identical behaviour on every run, fully auditable. An optional LLM adapter (off by default) is consulted only when the parser cannot read a brief, and its extraction is validated field-by-field in code before it can become a plan.
  • Tool-call-shaped action layer. The four typed actions are also published as tool definitions in MCP / PayPal Agent Toolkit shape (GET /api/agent/tools for discovery; POST /api/agent/tools drafts a source: "tool" plan from one call or a short sequence). Schemas are closed objects carrying the same field rules as the REST drafts, unknown tools and undeclared arguments are rejected, and conversion delegates to the resource drafts so a tool step is identical to the equivalent draft step — tool calls draft plans, they never bypass the approval gate.
  • Ask-the-grid. Plain-language questions ("Show everything still unpaid") compile into validated AG Grid filter models and visibly reconfigure the console.
  • One API map, machine- and human-readable. GET /api/openapi serves an OpenAPI 3.1 document covering every HTTP operation in the app (29 paths, 31 operations), and the /api-docs page renders the same registry as a browsable page grouped by surface — there is exactly one list of operations in the codebase, so the two can never drift apart, and a test walks the route files on disk and fails if a route is added without a document entry. The document declares no host and no credentials, and every operation carries security: [] with the honesty note that this API has no authentication layer — the approval gate is an application invariant, not authentication.
  • Structured API errors (a misconfigured instance explains itself). The deployment-readiness case no longer ends in an opaque framework 500 anywhere: every runtime-backed API route acquires its runtime through acquireRuntime (src/lib/api/errors.ts), which translates the one known failure — sandbox mode selected without credentials — into a structured 503 in the API's one shared error shape ({ error }, naming the missing variables and the mock-mode alternative, never a credential value). Any other failure is rethrown untouched, not dressed in a friendly shape. The OpenAPI document and the /api-docs page carry the same shared 503 description on every runtime-backed operation via apiOperationResponses; the only operations without it are the three that never build the runtime (GET /api/health, GET /api/openapi, and the tool definitions' GET /api/agent/tools), which stay up on a misconfigured instance — so the health report that diagnoses the problem and the API map that documents the remedy are both still reachable exactly when they are needed.
  • Unknown API routes (the JSON 404). Every response the API surface can produce now uses the one shared error shape: a path under /api/ that no route implements is answered by an optional catch-all route (src/app/api/[[...slug]]/route.ts, delegating to src/lib/api/unknown-route.ts) with a JSON 404 in the same shared { error } shape — naming the requested path and pointing at GET /api/openapi — for every method, instead of the page-level HTML not-found an API consumer cannot parse. The catch-all builds no runtime, so unknown paths answer 404 even on a misconfigured instance whose implemented routes answer the structured 503; concrete routes always take precedence, and page paths are untouched (a mistyped page URL still lands on the branded HTML 404). The catch-all is deliberately not a documented OpenAPI operation — the document stays at 29 paths / 31 operations.
  • Method not allowed (the JSON 405). A documented API path called with a method it does not support (POST to a GET-only route, DELETE on a read model) is answered by the app's one middleware (src/middleware.ts, delegating to src/lib/api/method-not-allowed.ts) with a JSON 405 in the same shared { error } shape — naming the requested method and path, the methods that do work, and pointing at GET /api/openapi — plus an Allow header, instead of the framework's empty-body 405. The allowed methods resolve from the OpenAPI operation registry itself, so there is no second path→method table to drift; HEAD on a GET path and OPTIONS pass through, unknown paths pass through to the JSON 404, and the OpenAPI document and /api-docs page declare the same shared 405 on every operation. The middleware builds no runtime, so a wrong-method call gets its 405 even on a misconfigured instance.
  • JSON body validation (the non-object 400). Every POST route whose body is a required JSON object reads it through one shared reader (readJsonObjectBody, src/lib/api/body.ts): malformed JSON and well-formed non-object JSON (null, an array, a scalar) alike answer the route's documented 400 in the same shared { error } shape, instead of slipping through a cast and crashing a field dereference into an opaque 500. Field types are checked after the shape — a numeric brief is the plan route's 400 "Missing 'brief'", never a parser crash. The one exception is POST /api/agent/suggest, whose body is optional — it reads the raw text itself and applies the same object check (isJsonObject) to a present body; routes that take no body (Judge Mode seed / pay / reset) read none. Completeness is test-enforced: no route file keeps a bare await req.json() cast, so the API's client-error family (400 body / 404 unknown / 405 method / 406 not acceptable / 413 oversized / 415 media type / 429 rate limited / 503 misconfigured) is uniformly structured JSON.
  • Request body size limit (the JSON 413). The same shared reader enforces one size limit on every JSON request body: 131072 bytes (128 KiB), measured in UTF-8 bytes on the raw body before parsing, so an oversized body answers a JSON 413 in the shared { error } shape — naming the limit — whether or not it is well-formed, and never reaches a validator, the runtime, or PayPal. Every legitimate body is far smaller (briefs and text fields are capped at 500 characters, payout batches at 25 lines, tool sequences at 4 calls — the largest legitimate body, a maximum-size payout batch draft, is roughly 27 KB, and the test suite pins that headroom), so the limit bounds parse cost on a hosted instance that deliberately ships with no authentication layer without ever rejecting a valid request. The suggest route applies the same limit to its raw text, and the OpenAPI document and /api-docs page carry the same shared 413 on every body-bearing operation via apiOperationResponses.
  • API cache control (no-store). Every response under /api/ carries Cache-Control: no-store, configured in next.config.mjs as a header rule scoped to /api/:path* only — so it covers every API response shape uniformly: route-handler 200s, the catch-all's JSON 404, the middleware's JSON 405, and the structured 503 alike. API responses carry live money-shaped state — console read models, plan state, the KPI summary, the readiness report — and no browser or shared cache may serve a stale copy of them: a cached summary still showing an invoice as outstanding after its payment webhook landed would contradict the audit trail the product exists to keep. Pages are deliberately not covered — they are server-rendered shells whose data comes from the API at runtime — and no cacheable directive (public, s-maxage, immutable) is declared anywhere.
  • Content-Type enforcement (the JSON 415). The same shared reader refuses a declared non-JSON media type before the body is read at all: a request whose Content-Type header is present and is not application/json (or an application/*+json suffix type; parameters such as charset are ignored) answers a JSON 415 in the shared { error } shape — naming the required Content-Type: application/json — and reaches no parser, validator, runtime, or PayPal call. A request that declares no Content-Type is read as before, so simple clients that send JSON without declaring it keep working; the check governs what a client declares. The suggest route applies the same check to a present body, and the OpenAPI document and /api-docs page carry the same shared 415 on every body-bearing operation via apiOperationResponses.
  • Content negotiation (the JSON 406). Middleware (src/lib/api/not-acceptable.ts, run after the method guard) enforces the response side of the media-type contract: every route under /api/ returns application/json only, so a request whose Accept header excludes JSON — naming only non-JSON types, or refusing it with application/json;q=0, the most specific matching range governing per HTTP semantics — answers a JSON 406 in the shared { error } shape, naming the path. A request that sends no Accept header, or accepts JSON directly or via a wildcard range with q > 0, is served as before; a +json suffix range does not claim plain JSON. Two exemptions are deliberate: OPTIONS (the framework answers it itself) and the PayPal webhook listener, whose sender is a machine peer delivering an event, not a client negotiating a representation — its deliveries are never refused over an Accept header. The OpenAPI document and /api-docs page carry the same shared 406 on every operation except the webhook listener via apiOperationResponses.
  • Rate limit (the JSON 429). Middleware (src/lib/api/rate-limit.ts, run after the method and negotiation guards) bounds how many POST requests one client can make: 120 POSTs per 60-second fixed window per client key (the first x-forwarded-for entry, else x-real-ip, else a shared "direct" bucket — a flood brake, not an identity; the headers can be forged on an instance exposed without a trusted proxy). The body size limit bounds what one request costs; this bounds how many requests there are, on a hosted instance that deliberately ships with no authentication layer. A client past the limit answers a JSON 429 in the shared { error } shape — naming the limit — with a Retry-After header carrying the seconds until its window resets, and the refused request reaches no route, runtime, or PayPal call. Reads are never counted, so console reloads and health probes cannot trip it; the webhook listener is not exempt (PayPal retries deliveries answered with an error status, and verified events are processed exactly once, so a refused delivery is delayed, not dropped). State is per-process in-memory. The window itself is sanitised like the adapters' timeout bounds: a non-positive or non-finite configured window falls back to RATE_LIMIT_WINDOW_MS (60 seconds) instead of driving the window comparisons raw — 0 or a negative would have made every request open a fresh window, so the flood brake would silently never have engaged, and NaN or Infinity would have made the window never expire. A valid window is honoured exactly. The limit itself is sanitised the same way: a non-finite configured limit falls back to RATE_LIMIT_MAX_POSTS (120) instead of driving the count comparison raw — NaN would have made the limiter a near-block-all reporting "NaN POST requests", and Infinity would have silently disarmed the flood brake — and a fractional positive limit is floored to the whole requests it admits, so the 429 message always names the integer limit truly enforced; a finite non-positive limit remains the deliberate block-all case. The OpenAPI document and /api-docs page carry the same shared 429 on every POST operation via apiOperationResponses.
  • Sandbox request timeout (no PayPal call can hang). Every outbound call the sandbox adapter makes (src/lib/paypal/sandbox.ts) — the OAuth token fetch and the shared request helper every resource call flows through (Invoicing, Orders, Payouts, Disputes, Transaction Search, webhook verification) — carries an AbortSignal and is bounded by a per-request timeout (SANDBOX_REQUEST_TIMEOUT_MS, 10 seconds by default, the same bound the LLM adapter enforces; injectable via SandboxConfig.timeoutMs, with non-positive or non-finite values falling back to the default). A PayPal endpoint that accepts a connection and never answers is aborted and translated into a PayPalError with status 504 and name REQUEST_TIMEOUT, naming the timeout and the request (method + path only, query string stripped — never a credential, never a header); any other transport failure is translated as a structured 502 CONNECTION_FAILED (see the connection-failure highlight below). No outbound call the app makes — PayPal or LLM — can now hang indefinitely.
  • Request id (the X-Request-Id header). Every response under /api/ — routed responses and middleware guard refusals (405 / 406 / 429) alike — carries an X-Request-Id header (src/lib/api/request-id.ts, resolved by the middleware before any guard runs), so any exchange an operator or a judge needs to report can be named by one id. A client that sends a valid X-Request-Id (1–128 characters of ASCII letters, digits, ., _, -) gets that exact value echoed back; a missing or invalid value is never echoed (reflecting arbitrary header text would be a header-injection / log-forging surface) and is replaced with a generated req-<uuid>. The id is a correlation handle, not an identity. The OpenAPI document declares the header on every response of every operation.
  • Webhook verification unavailable (the third outcome, the structured 502). Signature verification has three outcomes, not two: verified, rejected — and unavailable. In sandbox mode verification is itself a PayPal API call (POST /v1/notifications/verify-webhook-signature), and that call can time out, be refused, or fail upstream. When it cannot be completed, the handler reports verification_unavailable: the event is left unprocessed (never recorded as a signature rejection — the signature was never judged — its idempotency marker untouched, nothing resumed), the audit log names the outage distinctly (VERIFICATION UNAVAILABLE), and POST /api/paypal/webhooks answers a structured 502 in the shared { error } shape so PayPal's retry loop redelivers the event once verification recovers instead of the delivery being dropped. A redelivery after recovery processes normally and closes its loop exactly once. The OpenAPI document declares the 502 on the webhook operation.
  • Outbound response-size bounds (no adapter buffers an unbounded response). The sandbox request timeout bounds how long an outbound call can run; this bounds how much it can bring back. The sandbox adapter measures every response body in UTF-8 bytes before parsing it (readBody in src/lib/paypal/sandbox.ts), so API bodies, error bodies, and the OAuth token response are all covered uniformly; a body over SANDBOX_MAX_RESPONSE_BYTES (4 MiB by default) is refused with a PayPalError of status 502 and name RESPONSE_TOO_LARGE naming the limit only — never any of the body. The LLM adapter (src/lib/agent/llm.ts) does the same at its own bound: a body over LLM_MAX_RESPONSE_BYTES (256 KiB by default) is refused before parsing with an LlmExtractionError naming the limit only, and an oversized LLM response still surfaces the deterministic parser's original error through parseBriefWithFallback. Both limits are injectable, with non-positive or non-finite values falling back to the defaults. On both adapters, a fractional maxResponseBytes is floored to the whole bytes it admits — the limit the byte-length comparison truly enforces, and the integer the refusal names — and a sub-1 fraction falls back to the default bound, like a non-positive value; a valid integer limit is honoured exactly. A misbehaving or impersonated endpoint can no longer answer either adapter with an arbitrarily large body on the deliberately unauthenticated hosted demo.
  • Outbound body-read timeout (the per-request timers span the response body). The response-size bounds limit how much an outbound call can bring back; this limits how long bringing it back can take. Both adapters' per-request timers stay armed while the response body is read, not just while waiting for the headers — the sandbox adapter reads every body inside the timed call (fetchAndRead in src/lib/paypal/sandbox.ts), so an endpoint that answers promptly and then drips — or never finishes — its body, API or OAuth token alike, is aborted as the same 504 REQUEST_TIMEOUT naming method + path only, while the size bound's own RESPONSE_TOO_LARGE refusal is rethrown untouched. The LLM adapter keeps its timer armed across res.text() too: a hanging body surfaces as an LlmExtractionError timeout, and parseBriefWithFallback honesty is unchanged. A slow body finishing inside the bound succeeds on both adapters. On both adapters the per-request timeout is floored to the whole milliseconds it admits — the integer delay the abort timer truly enforces, and the value the timeout refusal names — and a sub-1 fraction falls back to the default bound the same way.
  • Sandbox connection failure (the structured 502 CONNECTION_FAILED). The timeout bounds a call that never answers in time and the size bound refuses an oversized body; this covers the remaining case — a call that gets no response at all (refused connection, DNS failure, reset mid-body). The sandbox adapter translates it at its one choke point (fetchAndRead in src/lib/paypal/sandbox.ts) into a PayPalError of status 502 and name CONNECTION_FAILED naming method + path only, never the transport error's own text (which can embed the full request URL). The precedence is fixed: an already-PayPalError refusal (the size bound's RESPONSE_TOO_LARGE) passes through untouched, and a timer-caused rejection stays the 504 REQUEST_TIMEOUT — never dressed up as a connection failure. The OAuth token fetch rides the same path, and through the webhook handler the failure surfaces as verification_unavailable, so an unreachable sandbox answers structured 502s instead of opaque HTML 500s and PayPal redelivers once it recovers.
  • LLM connection failure (the fixed failure message). The sandbox translation above covers a PayPal call that gets no response; the LLM adapter (src/lib/agent/llm.ts) now does the matching thing on its side. A call that gets no complete response — a refused connection, a DNS failure, a reset before or during the body, an Error rejection or not — surfaces as an LlmExtractionError with one fixed message ("LLM request failed before a complete response was received"), never the transport error's own text, which can embed endpoint internals. The precedence matches the sandbox side: an HTTP error status keeps its shape, and a timer-caused abort stays the timeout, never dressed up as a connection failure. parseBriefWithFallback honesty is unchanged — the failure still surfaces the deterministic parser's original BriefParseError, so a caller sees one error shape, never a raw model or transport error.
  • Sandbox invalid success response (the 502 INVALID_RESPONSE). The timeout bounds how long a PayPal call can run, the size bound limits how much it can bring back, and the connection translation covers a call that gets no response at all; this covers the remaining silent case — a call that succeeds with a body that cannot be what PayPal sent. The sandbox adapter's one body-read point (readBody in src/lib/paypal/sandbox.ts) refuses a success body that is not valid JSON, or that parses to a scalar / array / null instead of an object, with a PayPalError of status 502 and name INVALID_RESPONSE naming the request (method + path) only, never any of the body. Every PayPal success body is JSON and every endpoint the adapter calls answers with a JSON object, so anything else (a proxy or WAF HTML page returned with a 200, say) previously mapped silently to an empty-ish resource indistinguishable from "PayPal has nothing". Error responses keep their exact previous mapping, JSON or not, and the empty success bodies PayPal legitimately returns (send-invoice 202, remind 204) are unaffected.
  • LLM invalid success envelope (the fixed LlmExtractionError). The sandbox invalid-success refusal above covers a PayPal call that succeeds with an unusable body; the LLM adapter (src/lib/agent/llm.ts) now closes the matching gap on its side. Its extract() validated the response body and the choice's message as objects but dereferenced the choice entry itself, so a 200 response with choices: [null] escaped as a raw TypeError. The first choice entry now passes through the asRecord validator before its message is read, so a null / string / number / boolean / array entry is refused as an LlmExtractionError with the fixed message "LLM choice must be an object" — never a raw TypeError, and no part of the response body is carried. Every neighbouring envelope refusal keeps its exact previous message, a valid envelope still extracts, and parseBriefWithFallback honesty is unchanged — the failure still surfaces the deterministic parser's original BriefParseError.
  • Sandbox OAuth token refresh (one 401, one retry). The sandbox adapter caches its OAuth2 access token until its locally computed expiry, but PayPal can stop accepting a token before that instant (early expiry, revocation, or an overstated expires_in). A 401 now invalidates the cached token — only if the cache still holds the exact token that was used — fetches a fresh token, and retries the call exactly once; a mutating retry reuses the same PayPal-Request-Id, a second 401 surfaces as the ordinary PayPalError, and non-401 failures never refresh. A token response whose expires_in is non-numeric, non-positive, or non-finite falls back to the default 3600-second lifetime instead of poisoning the cache expiry.
  • Sandbox OAuth token-fetch coalescing (one fetch, every waiter). The sandbox adapter's token fetch is now shared in flight: concurrent callers on a cold or just-expired cache — the console's read routes fan out in parallel, the summary route calling five list methods at once — get one token-endpoint call and proceed on the same token, instead of each racing its own credential exchange. Only the caller that started the fetch clears the slot, as the fetch settles, so a failed shared fetch is never retained: every waiter rejects with the same PayPalError and the next call retries the endpoint. The 401 refresh path shares the same fetch, so parallel calls holding one stale token trigger one refresh, not one per call.
  • Sandbox list pagination (the whole ledger, or a loud refusal). Every sandbox list endpoint is paged by PayPal; the adapter's four list methods now follow each response's HATEOAS next link until a page carries none, concatenating pages in order with every mapping untouched — a list with no next link still costs exactly one request. Only the href's path + query are used, against the adapter's configured base URL, and following is bounded by SANDBOX_MAX_LIST_PAGES (10): a chain that has not ended by then is refused as a 502 PAGINATION_LIMIT instead of looping forever or silently truncating the ledger.
  • Sandbox transient-error retry (reads retry once; mutations never do). A GET call that PayPal answers 429 or 503 is retried exactly once after a bounded delay — the response's Retry-After delta-seconds when usable, otherwise 250 ms, never more than SANDBOX_TRANSIENT_RETRY_MAX_DELAY_MS (2,000 ms) — so a console refresh racing a sandbox rate limit returns the state instead of an error. A second transient answer surfaces as the ordinary PayPalError; mutations are never retried on a transient answer, because their outcome is not knowable from the status alone; the OAuth token fetch is outside the retried path; and the retry composes with the 401 token refresh, each mechanism firing at most once per logical call.
  • LLM timeout config hygiene (a malformed timeout cannot time out every call). The LLM adapter's per-request timeout is now sanitised the same way the sandbox adapter's is: the effective timeout is the configured timeoutMs only when it is finite and positive, otherwise the named default LLM_REQUEST_TIMEOUT_MS (10,000 ms). Previously the raw value armed the abort timer, so 0, a negative, or NaN fired at ~0 ms and Infinity overflowed to ~1 ms — one malformed config value timed out every LLM call before any endpoint could answer. A valid timeout is honoured exactly, the timer still spans the response body, and parseBriefWithFallback honesty is untouched.
  • Rate-limit window config hygiene (a malformed window cannot disarm the flood brake). The rate limiter's window is now sanitised the same way the adapters' timeout bounds are: the effective window is the configured windowMs only when it is finite and positive, otherwise the named default RATE_LIMIT_WINDOW_MS (60,000 ms). Previously the raw value drove every window comparison, so 0 or a negative made every request open a fresh window — the flood brake silently never engaged — and NaN or Infinity made the window never expire with a NaN or infinite Retry-After. The limiter reports and enforces the effective window everywhere, the deliberate block-all limit is unchanged and reports the fallback window, and a valid window is honoured exactly. A fractional configured window is floored to the whole milliseconds it admits, and a sub-1 fraction falls back to the default like a non-positive value (see the fractional highlight below).
  • Rate-limit limit config hygiene (a malformed limit cannot disarm or over-arm the flood brake). The rate limiter's limit is now sanitised the same way its window is: the effective limit is the configured maxRequests only when it is finite — a non-finite limit (NaN, ±Infinity) falls back to the named default RATE_LIMIT_MAX_POSTS (120), and a fractional positive limit is floored to the whole requests it can actually admit (2.9 admits and reports 2; a sub-1 fraction floors to 0 and behaves exactly like the deliberate block-all case). Previously the raw value drove the count comparison, so NaN made the limiter a near-block-all reporting "NaN POST requests" and Infinity made the brake never engage. The limiter reports and enforces the effective limit everywhere, so the 429 message always names a real, integer limit; a finite non-positive limit remains the deliberate block-all case, unchanged, and a valid integer limit is honoured exactly.
  • Outbound response-size config hygiene (a fractional bound names the limit truly enforced). Both outbound adapters' response-size bounds are now floored the same way the rate limiter's limit is: a fractional maxResponseBytes is floored to the whole bytes it admits, so the RESPONSE_TOO_LARGE refusal names that integer. Previously a fractional bound such as 1024.9 was enforced as 1024 whole bytes while the refusal named a "1024.9-byte limit" — a limit that can never be the one truly enforced — and a sub-1 fraction such as 0.5 refused every non-empty body while naming half a byte. A sub-1 fraction now falls back to the default bound (4 MiB sandbox, 256 KiB LLM) like a non-positive value, and a valid integer limit is honoured exactly.
  • Outbound timeout config hygiene (a fractional bound names the timeout truly enforced). Both outbound adapters' per-request timeouts are now floored the same way their response-size bounds are: a fractional timeoutMs is floored to the whole milliseconds it admits, so the timeout refusal names that integer. Previously a fractional timeout such as 30.9 was enforced as 30 ms — the runtime's abort timer enforces an integer delay — while the refusal named a "30.9 ms" timeout, a value that can never be the one truly enforced, and a sub-1 fraction such as 0.5 timed every call out while naming half a millisecond. A sub-1 fraction now falls back to the default bound (10,000 ms on both adapters) like a non-positive value, and a valid integer timeout is honoured exactly.
  • Rate-limit window fractional hygiene (a fractional window names the window truly enforced). The rate limiter's fixed window is now floored the same way its limit and the outbound adapters' bounds are: a fractional windowMs is floored to the whole milliseconds it admits, so the 429 message names that integer window. Previously a fractional window such as 5500.9 was reported as a "5.5009 seconds" window — the limiter runs on an integer-millisecond clock and its 429 message names windowMs / 1000 seconds, so no integer-millisecond comparison enforces the raw fraction as stated — and a sub-1 fraction such as 0.5 opened a fresh window on the next whole millisecond, a window that is no window at all. A sub-1 fraction now falls back to the named default RATE_LIMIT_WINDOW_MS (60,000 ms) like a non-positive value, the deliberate block-all limit now reports the floored/fallback window, and a valid integer window is honoured exactly.
  • Sandbox base-URL normalisation (a misconfigured base URL can no longer produce relative or double-slash request URLs). The sandbox adapter's constructor now trims the configured base URL and strips ALL trailing slashes; a value that is empty once normalised (blank, or slashes only) falls back to the named default SANDBOX_DEFAULT_BASE_URL instead of producing relative request URLs. Previously only one trailing slash was stripped and nothing was trimmed, so a base URL pasted as https://api-m.sandbox.paypal.com// (or with surrounding whitespace) survived into every request as a double-slash or whitespace-prefixed URL no PayPal route matches — the first symptom would have been opaque 404s on the live checks.
  • LLM base-URL normalisation (the same rule on the env-built and directly constructed paths). The shared normalizeLlmBaseUrl trims, strips all trailing slashes, and falls back to the named LLM_DEFAULT_BASE_URL when the value is empty once normalised; it runs in both the env factory and the adapter constructor, which exposes the normalised result as a readonly baseUrl (mirroring the sandbox client's). Previously a slashes-only env value normalised to the empty string and produced the relative request URL /chat/completions, and a directly constructed adapter carried its base URL verbatim into double-slash request URLs no OpenAI-compatible route matches.
  • LLM model normalisation (a blank model is never sent to the endpoint). The shared normalizeLlmModel trims the configured model and falls back to the named LLM_DEFAULT_MODEL on a blank value, applied in both the env factory and the adapter constructor, which exposes a readonly model. Previously a directly constructed adapter carried its model verbatim into every request body — a padded name was sent padded, and a blank or whitespace-only name was sent as an empty model no OpenAI-compatible endpoint accepts.
  • Sandbox credential normalisation (blank credentials fail loudly, the way the health report says they should). The sandbox constructor trims the configured client id, client secret, and webhook id; a whitespace-only credential counts as missing and is named by PayPalNotConfiguredError — matching the health report's non-blank rule, which already reported that same value as unset (the readiness surface and the adapter used to disagree about whether the instance was configured at all) — and a whitespace-only webhook id counts as unconfigured, so signature verification returns false with no network call. No credential value is logged or carried in any error.
  • PAYPAL_MODE normalisation (the factory and the health report can no longer disagree about which adapter is live). One shared normalizePayPalMode — trimmed, lower-cased, only sandbox selects sandbox — is used by both the client factory and the health report. Previously a padded value such as sandbox (a common copy-paste or env-file accident) was reported as sandbox by GET /api/health while the factory silently ran the mock adapter: an operator who believed they had deployed sandbox was in fact running fictional mock data. Now the padded value selects the sandbox adapter and fails loudly without credentials; padded mock, blank, and unknown values stay mock, and factory and health agree on the effective mode for every spelling.
  • Parser due-date hygiene (an impossible due date is refused, never carried into a plan). The deterministic brief parser now enforces the real-calendar rule every other due-date surface already enforced. Previously an ISO-shaped but impossible date ("due 2026-02-30") was returned verbatim, so a plan carried an impossible invoice due date into the executor, and an out-of-range relative offset ("in 9999999 days") returned a corrupt extended-year string — a larger offset ("in 999999999999 days") escaped as a raw RangeError, an opaque 500 at POST /api/agent/plan instead of the documented 422. Both shapes are now refused with a BriefParseError naming the rule; a real leap day and the ordinary relative forms are unchanged.
  • Parser amount hygiene (a non-positive or over-cap amount is refused, never carried into a plan). The deterministic brief parser now enforces the amount rule every other amount surface already enforced — positive and at most 100,000. Previously its money helper formatted whatever the brief captured verbatim: "$0" (and a commas-only amount, which parses to zero) produced a normal plan carrying 0.00 into the executor, an over-cap amount was carried unchecked, and a huge amount was formatted into an exponential money string ("1e+22"). All three money actions (invoice, payout in both word orders including a payment-gated payout, and sell) now refuse with a BriefParseError naming the rule and produce no plan at all; boundary amounts ($0.01, exactly $100,000) and ordinary comma-grouped amounts are unchanged.
  • Sub-cent amount hygiene (a positive amount that rounds to 0.00 is refused on every amount surface). The structured validators previously checked the pre-rounding number, so a sub-cent amount (0.001–0.004) passed every "greater than zero" check and was emitted as a "0.00" money value — a drafted invoice, order, or payout carried 0.00, and the record-payment leg recorded a 0.00 payment as success. The formatted two-decimal value is now itself checked on every amount surface — the shared drafts validator (and with it the /api/paypal/* draft wrappers, plan-amount edits, and agent tool calls), the LLM extraction validator, the record-payment leg, and the invoice / sell / batch console form mirrors — and refused naming the 0.01 floor before any adapter call. The brief parser was already safe by construction (it captures at most two decimals); 0.01 is accepted, and 0.005 is accepted as the "0.01" it formats to — the rule governs the emitted value, not the input.
  • Mock adapter money validation (the demo's PayPal stand-in checks its own inputs). The mock adapter now validates money at its own boundary, before any state changes. Previously its payout check let a non-numeric amount through (NaN comparisons are false), so "abc" was created as SUCCESS with a "-abc" transaction; invoice totals could sum to "NaN" or go negative via a negative quantity; a "-5.00" order captured as a "-5.00" transaction; and a recorded payment of "-50.00" increased the amount due, a zero payment flipped an invoice to PARTIALLY_PAID, and an overpayment was silently clamped by Math.max. Payout / invoice unit / order amounts and invoice quantities must now be finite and greater than zero (fractional quantities such as 2.5 stay legitimate, an invoice needs at least one item), and a recorded payment must be finite and positive, in the invoice's own currency, and no more than the amount due — an overpayment is refused, never clamped, matching the record-payment leg's stated rule. Create-call refusals are 400 VALIDATION_ERROR, recorded-payment refusals are 422 UNPROCESSABLE_ENTITY, and every refusal leaves all state untouched.
  • Sandbox money-mapping hygiene (a malformed PayPal amount can no longer poison the ledger with NaN). Every amount the sandbox adapter maps from a PayPal response passes through one mapper, and every downstream consumer converts that amount with Number(...) — the console read models and KPI totals, the invoice-detail line totals, and the record-payment leg's amount-due arithmetic. The mapper accepted any non-empty string, so a malformed amount in a response (a non-numeric string from a proxy-shaped or non-conforming payload) was carried verbatim and every one of those conversions produced NaN — and because NaN comparisons are false, a record-payment against such an invoice would have recorded a "NaN" payment instead of refusing, the exact slip the mock adapter's money validation closed at its own boundary. An amount is now accepted only when it parses to a finite number (negative amounts stay valid: Transaction Search reports signed amounts); anything else falls back exactly as a missing amount already did. An invoice item's quantity is sanitised the same way (a non-numeric quantity maps to the "1" an absent quantity already mapped to), and the items-total fallback sums with finite-or-zero factors, so the fallback total can never be NaN either. Valid PayPal amounts are preserved verbatim.
  • Judge Mode. One click seeds a fictional seller history plus the demo plan, so the full invoice → paid → payout loop runs with no PayPal account, no keys, and no install — and a mock-only reset makes the demo repeatable for the next judge. Seeding is mock-mode only too, and refuses in sandbox mode before touching anything (a 409 from POST /api/judge/seed): in sandbox the fictional history would be created at PayPal for invented recipients, and the captured order and the flagged sample dispute only exist in the mock. The payment simulation is mock-mode only too, and refuses in sandbox mode before touching anything (a 409 from POST /api/judge/pay): the spine invoice only exists in the mock, and a sandbox invoice is paid by the buyer on PayPal's hosted page (reconciled by webhook/sync) or recorded through the general record-payment route, which remains available in sandbox mode.
  • PayPal mode indicator. The console's mode strip states the instance's actual PayPal mode on screen — Mock, Sandbox, Sandbox without a webhook id (the sync reconciler named as the fallback), or Sandbox misconfigured (the missing credential variables named as the remedy) — resolved from GET /api/health through a view-model that keeps only whitelisted fields and builds its copy from configuration booleans, so no credential value can reach the screen. A judge watching a loop close can tell from the screen whether it closed against the mock adapter or the live sandbox; an unreadable health report resolves to "unknown", never a guessed mode.
  • Site navigation. A primary nav in the root layout puts every addressable surface — Console, Command, Invoices, Checkout, Payouts, Disputes, Reconciliation, Activity, Judge Mode, API docs — one click away from every page, with aria-current="page" on the current page's link. The link list lives in a single source of truth (src/lib/nav/site-nav.ts), and a test walks the app's page routes on disk and requires exact agreement, so a new page cannot ship unnavigable and a removed page cannot leave a dead link; dynamic detail routes are deliberately not nav destinations, and no external URL is listed.
  • Read-only risk surfaces. Open disputes with response deadlines and a draft-only evidence summary (never submitted), plus the signed Transaction Search ledger the KPI totals reconcile against.

How it was built

A single Next.js (App Router) + TypeScript app. A PayPalClient adapter contract mirrors the PayPal REST resources one-to-one, with two implementations: a full in-memory mock adapter (the default — the whole loop runs with zero credentials) and a sandbox adapter implementing the live REST calls (OAuth2 client-credentials, Invoicing v2, Orders v2, Payouts, Disputes, Transaction Search, and the documented webhook verify-signature call). Webhooks are signature-verified before they are trusted and processed exactly once by event id; a polling sync reconciler covers instances webhooks cannot reach. The agent pipeline (parser → plan → approval gate → executor → audit log) is the only path from a brief to a PayPal call. The suite at submission time: 1181 automated tests, all passing, with typecheck and production build green and the same sequence running in CI (and runnable locally as one command, npm run verify).

Challenges and honest limits

  • Breadth vs. depth. Five PayPal API families in one loop is the project's strength and its main risk; it was controlled with a mock-first build order and an explicit cut list (CONCEPT.md §3.4) — in the end every cut-list item was built rather than cut.
  • Sandbox disputes cannot be reliably manufactured, so Judge Mode's seeded history includes one dispute row that is always visibly flagged "sample". No PayPal data is ever faked: sample data is labeled wherever it appears.
  • Sandbox status: live-verified. The sandbox adapter was exercised end to end against the live PayPal sandbox on 2026-10-16 via npm run smoke:sandbox (it gates on the health report before drafting anything, so it cannot pass against a mock or misconfigured target): an invoice was created and SENT (USD 10.00), a record-payment settled it as MARKED_AS_PAID (PayPal's status for externally recorded payments), a payout batch was accepted (USD 2.00), an order was created with a real PayPal checkout link (USD 5.00), and the sync reconciler answered. The live run found three adapter shapes the stub-fetch tests had modelled wrong — Payouts amounts carry currency, not currency_code; create-invoice answers 201 with a bare HATEOAS link object (no id, no links array); and the Payouts list endpoint answers 404 on some sandbox accounts while create + single-batch GET work, so listing falls back to per-batch GETs — each fixed and pinned by regression tests. The BUILD_LOG BLOCKERS items (sandbox account, Payouts enablement) are resolved, ahead of the Oct 24 hard deadline. The hosted Judge Mode demo still runs in mock mode, so judges need no PayPal account or keys.
  • AG Grid licensing discipline. Row grouping is an AG Grid Enterprise feature, so it is deliberately unused; the project ships Community-tier features only, with no trial watermark anywhere in the demo.
  • Money is never summed across currencies — every total in the console is grouped per currency (USD / EUR / GBP).

What's next

A hosted demo that stays up for judging, webhook delivery exercised against the hosted instance (the local paths use the sync reconciler), and — after the hackathon — saved brief templates and recurring payout runs for the freelancers LedgerPilot was built for.

Built With

Share this project:

Updates

Submission history