# Finance Portal

Finance Portal aims to make Finance workflows visible across systems: upstream
and downstream dependencies, scheduling and execution results, failure alerts,
and individual run history. Bounded accounting-period execution builds on this
visibility. Data readiness is not proof that invoicing or accounting is complete.

## Design and implementation status

The sole design/status source of truth is
[Finance Portal — v7 (2026-09-07)](/Users/amber/Obsidian/Codex/projects/finance-close-control-plane.md).
It lives in the local Obsidian vault, not this repository; the absolute link is
workstation-specific. Read its implementation evidence (§3), task catalog/DAG
(§4–6), recovery/acceptance contracts (§10–16), and open owner gates (§17).
Do not treat the target design as implemented capability or execution approval.

The frontend uses native HeroUI v3 components and its default Dark theme, with
React 19 and Tailwind CSS 4. Its current
navigation, UI acceptance evidence and remaining boundaries are maintained in
the same design SSoT (§12, HeroUI frontend redesign), not a separate status list.
Run `npm test` and `npm run build` from `web/` to check frontend changes.

The dependency page includes an interactive graph driven by the task catalog and
declared edges. Node selection highlights related paths and opens inspection,
run-history and retry-condition entry points. Zoom, overview and canvas panning
are presentation-only. Shared job outcomes do not become Finance-subset outcomes,
and missing per-table evidence stays unknown. Retry remains unavailable until the
server's execution contract is bound; the diagram does not dispatch provider jobs.
For versioned local deployments, set `FINANCE_PORTAL_WEB_DIST` to the deployment's
copied frontend build rather than the mutable development `web/dist` directory.

The page refresh button performs uncached GET reads with a 20-second timeout;
it does not rerun upstream jobs or generate billing data. Failed reads keep
previous results visible with a stale-data warning. Run history auto-refresh
pauses after loading older pages; manual refresh returns to the first page
while retaining filters. See §12 of the design SSoT for refresh acceptance.

The repository implements a **local v7 observation foundation, not a
production-ready close executor**. See the design SSoT §3 for implementation
evidence and remaining provider/execution gaps. Business acceptance remains
separate from local tests and builds.

The Portal does **not** call the old `netforce-hub` Cloud Run service. Retirement
of that deployment is a separate authorized change after shadow and business
acceptance, not a consequence of starting this prototype.

The default `demo` mode uses a deterministic runner and persists local state;
keep its state path isolated from real environments. `observe` queries configured
live dependencies with read-only credentials and denies every POST. Missing
credentials mean incomplete observation, not a fallback to accepted demo data.
`direct` business commands now fail closed until the design's implementation
and acceptance gates are met. Alert acknowledgement has separate permissions.

## Run locally

```bash
cd web
npm install
npm run build
cd ..
go run ./cmd/finance-portal
```

Open `http://127.0.0.1:8870`.

For frontend development:

```bash
go run ./cmd/finance-portal
cd web && npm run dev
```

Vite proxies `/api` to the Go service.

## Direct execution

**Not an approved production runbook.** `FINANCE_PORTAL_EXECUTION_MODE=direct`
does not currently admit business commands. The legacy write switch and mutation
token cannot bypass unbound v7 execution contracts.

Important variables:

- `FINANCE_PORTAL_STATE_PATH` — local control-state JSON path (prototype,
  not a production control store).
- `FINANCE_PORTAL_MUTATION_TOKEN` — bearer token required for POST actions.
- `FINANCE_PORTAL_ORDER_SNAPSHOT_DATE` — explicit `YYYY-MM-DD` order date
  selector; this is not an immutable content snapshot.
- `FINANCE_PORTAL_GCP_PROJECT`, `FINANCE_PORTAL_BILLING_SOURCE`,
  `FINANCE_PORTAL_ORDER_SOURCE`, `FINANCE_PORTAL_OUTPUT_DATASET` — BigQuery
  bindings.
- `FINANCE_MYSQL_HOST`, `FINANCE_MYSQL_USERNAME`, `FINANCE_MYSQL_PASSWORD`,
  `FINANCE_MYSQL_DATABASE`, `FINANCE_MYSQL_TLS` — Finance database runtime.

Legacy direct-mode configuration (insufficient to enable business commands):

- `FINANCE_PORTAL_WRITE_ACTIONS_ENABLED=true`;
- non-empty `FINANCE_PORTAL_MUTATION_TOKEN` supplied as a Bearer token.

## Live read-only observation

Set `FINANCE_PORTAL_EXECUTION_MODE=observe`. Credential values must be injected
at runtime (for example through Cloud Run Secret Manager bindings); never put
them in the repository or the control-state JSON.

- `FIVETRAN_API_KEY`, `FIVETRAN_API_SECRET` — Fivetran Basic Auth credentials.
- `FIVETRAN_CONNECTOR_ID` — defaults to the monthly AR connector
  `spake_desecrate`.
- `FINANCE_PORTAL_FINANCE_SOURCE_DSN` — a SELECT-only `mysql://` Finance TiDB
  URL. The Portal converts it to a driver DSN in memory.
- `FINANCE_PORTAL_FINANCE_HISTORY_API_URL` — optional explicit HTTPS Finance
  service base URL for GET-only audit collection when the DB reader is absent.
  It reads the existing `/quality/summary?limit=100` projection (at most 500 table
  audits); coverage is partial, not complete history or business readback. An
  API requiring authentication fails visibly until its read identity is bound;
  do not make a private service public to enable collection. Adding this binding
  preserves other provider history only when the old Finance namespace was
  unconfigured and contains no runs; changing an existing source is rejected.
- Application Default Credentials — BigQuery and Cloud Scheduler read access.
- `FINANCE_PORTAL_AR_TABLE`, `FINANCE_PORTAL_MONTHLY_OUTPUT_DATASET`,
  `FINANCE_PORTAL_CRM_CONTROL_DATASET` — optional physical bindings.
- `FINANCE_PORTAL_FINANCE_SERVICE_URL`, `FINANCE_PORTAL_FINANCE_SCHEDULER` —
  optional health and scheduler bindings.

`GET /api/v1/close-observations/current?mode=predicted|actual` is the single
live read path. It exposes provider execution and inferred output status in
separate fields. The prototype still uses row-count/time heuristics for some
checks; these do not prove exact scope, immutable lineage, or business acceptance.
The target observation and execution contracts are defined in the design SSoT.

The observer writes only a sanitized last-observation status file (mode `0600`)
for cold-start diagnosis. It contains counts, run IDs, timestamps, and status
messages, but no credentials or financial row payloads.

## Background operations observation

The operations workspace is the default page. Configure:

- `FINANCE_PORTAL_OPERATIONS_PATH`: SQLite observation database, default
  `data/<runtime-mode>-operations.db`. Keep it on a persistent local disk;
  ephemeral Cloud Run storage is not durable production storage.
- `FINANCE_PORTAL_ENVIRONMENT`: explicit environment label. The database rejects
  environment and configured source-binding changes to prevent history mixing.
- `FINANCE_PORTAL_ALERT_OPERATORS_JSON`: runtime-only array of objects with
  `actor` and `token`, defining separate alert acknowledgement identities.
  This is a local operator mechanism, not a production SSO/RBAC implementation.
- `FINANCE_PORTAL_NOTIFICATION_URL`: optional approved HTTPS webhook. Unset by
  default. It receives sanitized alert JSON with an `Idempotency-Key`; receivers
  must deduplicate. Delivery is at least once, with a maximum of three attempts.
- `FINANCE_PORTAL_DBT_ACCOUNT_ID`, `FINANCE_PORTAL_DBT_JOB_ID`,
  `DBT_CLOUD_API_TOKEN`: exact approved dbt job and a read-only API identity.
  `FINANCE_PORTAL_DBT_API_BASE` defaults to `https://cloud.getdbt.com`; use the
  account's full Admin API access URL, for example `https://ACCOUNT_PREFIX.us1.dbt.com`.
  The documented cell regions and legacy `emea.dbt.com` / `au.dbt.com` are supported;
  arbitrary domains, paths, ports and redirects are not. See the
  [dbt access URL contract](https://docs.getdbt.com/docs/platform/about-platform/access-regions-ip-addresses).
  Both RFC3339 and the API's space-separated timestamps with an explicit UTC
  offset are normalized to UTC; timestamps without a timezone are rejected.
  The collector checks every returned
  job identity, scans up to 5100 recent records per cycle and reports partial coverage.

In observe mode, minute-based background workers collect Fivetran sync history,
Finance Org/Account/Order table audit history, configured dbt job history,
CRM table-load history and the latest Scheduler attempt.
Finance DB collection scans a bounded 30-day audit window in pages of 200;
the alternative Finance API reader has the separate bounded projection described above.
CRM collection scans the Finance prerequisite table scope defined in
`internal/control/operations_crm_scope.go` (including required reference tables),
not the entire CRM database. It uses pages of 20, joins
only unique owner run IDs and reads the referenced manifest from the approved
NexusCRM GCS namespace at an explicit generation. Current manifest identity,
table declaration, SHA256 and pre-load time checks are shown separately from
design edges. They are not a loader-recorded generation or business acceptance.
Reads use at most four concurrent objects, 15 seconds per object, and drain at
most five advancing audit pages before normal polling. A cancelled
batch does not advance its checkpoint. Stored runs are deduplicated and retained.
A versioned scope checkpoint backfills newly included references without deleting
old runs. Task views show the latest audit for each exact table independently;
an absent table is unknown and one successful table cannot imply all inputs ready.
This observation scope is not an execution allowlist or the complete dbt lineage.
Fivetran uses a six-day paginated
window within its [seven-day API limit](https://fivetran.com/docs/developer-resources/rest-api/api-reference/connections/retrieve-connection-sync-history).
Scheduler's latest-attempt snapshot is complemented by an ADC-authenticated
Cloud Logging reader scoped to the configured job. It pages through a fixed
30-day window (200 entries per page), preserves the cursor transactionally and
rescans for delayed entries. Coverage remains partial due to log retention and
ingestion delay. Start/response entries remain separate evidence when no shared
attempt identity exists; neither is a fabricated provider run. Raw target URLs
and debug payloads are discarded. Missing source integrations remain visible in collection status.
History collection continues while pages are closed. Close snapshot reads also
use a background cache; an uncollected period returns 503 rather than querying
providers from a page request. No collector performs provider business writes.

The canonical NexusCRM binding also reads the two verified Cloud Run Jobs
`pingcap-nexuscrm-extract` and `pingcap-nexuscrm-load` in `gcp-digital-bi/us-west2`.
Only execution identity, timestamps, task counts and completion conditions are
requested; environment/arguments/provider messages are excluded. Up to five
100-entry pages are scanned, retaining the last 30 days and older active runs.
The extract execution, loader process and actual table publication remain
separate facts. A successful loader process is not proof of a new manifest,
updated target tables or financial acceptance. No job execution is triggered.
Shared extraction is a diagnostic `latest_job_run`, not a verified Finance-subset
execution. Shared-job failure alerts retain their original status and explicitly
mark Finance impact unverified; they do not establish affected table outcomes.

Back up SQLite using its online backup mechanism or stop this process before
copying the database and its WAL. Retention automation, multi-instance production
storage and all provider coverage remain subject to the design acceptance gates.

The HTTP contract is documented in [docs/api/openapi.yaml](docs/api/openapi.yaml).
Local reads are unauthenticated; Production deployment must put the whole
service behind Cloud Run IAM/IAP. Mutation endpoints additionally require the
Portal write gate and Bearer token above.

The monthly worker names its two BigQuery outputs with the close run ID, but
that naming alone does not establish immutable exchange or a complete downstream
handoff. See the design SSoT implementation gaps before using real-write mode.

## Verification

```bash
go test ./...
cd web && npm run build
python3 -m unittest discover script/bigquery -p 'test_*.py'
```

Production cutover is intentionally outside local build success. It requires a
shadow predicted close, a shadow actual close, business readback, and a separate
retirement change for the old Cloud Run deployment.
