Skip to content

Farm settings — profile (name/logo) + locale/timezone/currency editing + §4.6 currency-change guard (API + SPA) #123

Description

@mforce

Part of epic #14 (Phase 1.1). Spec: §3.2 (farms fields), §4.5 "Farm localization settings", §4.6 "Financial row currency immutability" + "Farm currency change rule", farm setup UC (§ "Tie a farm to a currency, locale, and timezone").

There is currently no endpoint or screen to edit farm settings at all — Features/Accounts/ holds only the repository interface. This slice adds farm-settings editing across API + SPA, with the §4.6 currency-change guard as the core rule.

Sequencing: #45 (i18n infrastructure) should land first — same phase, and the locale-change guardrail below needs the UI-language resolution chain from #45 to be testable. If this slice lands earlier, the locale→UI-language test moves to #45's checklist instead.

Scope

API:

  • GET + PATCH farm settings endpoint (admin-gated), covering: name, locale, timezone, currency_code, unit_system, first_day_of_week, date_format_override, time_format_override (name already exists in §3.2; the rest per §4.5)
  • Farm logo upload endpoint (admin-gated) + serve endpoint with cache headers; delete/replace supported (PR Farm logo: upload, serve and remove, with a no-decode image sanitizer (#123) #174). Two deviations from this line, both deliberate: the upload takes a RAW body, not multipart — multipart contributes only a filename and a declared content type, both of which the endpoint ignores by design, in exchange for a parser ahead of our code, a temp-file spill above 64 KB and an antiforgery exemption. And the bytes are NOT a bytea column on accounts: that row is read on every dated and every priced operation, and EF selects every mapped column, so a megabyte there would ride along on all of them. Still bytea, still one database to back up — in its own farm_logos table, with a bytes-free projection for "is there one, and which".
  • Logo upload validation (security): format decided by magic bytes (never extension or declared type), SVG refused, 1 MB cap, metadata stripped server-side (PR Farm logo: upload, serve and remove, with a no-decode image sanitizer (#123) #174). Chosen approach: validate and REWRITE the container without ever decoding pixels — re-encoding would add a third-party codec to the request path and take on the decompression-bomb cost only a decoder pays. Also refuses animation (an ANMF frame nests its own chunk stream a flat allowlist cannot sweep) and caps every declared dimension at 4096. Known limit, stated in the code: IDAT, iCCP and a JPEG ICC APP2 are variable-length by nature and can carry arbitrary bytes — no non-decoding sanitizer can prevent that.
  • §4.6 currency-change guard: block currency_code change with a 422 + stable machine-readable code once any sales_orders, payments, or expenses row exists for the farm; allowed when none exist
  • On allowed currency change: re-derive currency_symbol + currency_minor_unit from the static ISO 4217 lookup; fallbacks per §4.5 (symbol = code, minor unit = 2)
  • Validation: farm cannot be active without timezone, locale, and currency_code
  • Optimistic concurrency (version token) on the farm aggregate; mismatch → 409
  • Audit log event for settings changes (same-transaction, per existing audit pattern)

Web (SPA):

  • Farm settings screen (admin-only): farm name, logo upload/preview/remove, locale, timezone, currency, unit system, first day of week, date/time format overrides
  • Display farm name + logo in the SPA chrome (sidebar/header branding slot per web/DESIGN.md) — falls back to app branding when no logo set. SPA currently displays farm name nowhere
  • Currency field: disabled with explanation when financial rows exist (surface the guard before the user hits the 422)
  • Display formatting continues to follow farm.locale + currency fields + farm.timezone (§4.5 display rule) — settings changes reflect immediately after save
  • Date fields cap by FARM-local today, not browser-local. todayIso() is browser-local and is used as both the max and the initial value on every date input: Daily entry, flock placement (create + edit), bird movements, expenses (and its month picker), inventory purchase/usage, Reports to, Sales. Since Use farm-local dates for withdrawal restriction and allocation boundaries #35 the API rejects a future date against the farm's today, so a browser ahead of the farm can offer a date the server refuses, and a browser behind it hides a legitimate one. Feed farm.timezone from this slice's settings payload into one shared helper, and drop the duplicate local todayIso() definitions in InventoryPage.tsx:25 and SalesPage.tsx:19 (the shared one is src/lib/dates.ts). Caveat: server-side only Daily entry and bird movements are farm-local today — the rest is Sweep the remaining UTC date boundaries onto the farm-local clock (follow-up to #35) #155 — so the two need to land together for the pickers to match the API everywhere.
  • Unit price uses one minor unit, not two. SalesPage.tsx prefills the price by dividing the product's default by the product's currencyMinorUnit (lines ~139 and ~478) and submits by multiplying with the order's (parseMoneyToMinorUnits(price, active.currencyMinorUnit), ~211). If those ever differ, an accepted prefill is off by 100x — and it arrives as an explicit price, so the API's SalesOrder.ProductPriceCurrencyMismatch guard does not fire. Since PR Farm settings: editable localization block with the §4.6 currency lock (#123) #159 they cannot differ (a priced product locks the farm currency; a first price binds to the farm's), so this is latent, not live — but the SPA should read one minor unit, and that is the order's.
  • CSP needs img-src 'self' blob:. Security: forwarded headers, HSTS, CSP + security headers, pinned AllowedHosts #144/F144: forwarded proto, HSTS, CSP + security headers, pinned AllowedHosts #160 shipped img-src 'self', which blocks blob: URIs. The logo endpoint is auth-gated, so an <img src> cannot carry the Authorization header — the SPA has to fetch it through the API client and render from a blob URL, and that is blocked until the directive is widened. Deliberately not done in slice 2 (PR Farm logo: upload, serve and remove, with a no-decode image sanitizer (#123) #174): a CSP relaxation should land with the code and the test that prove it is needed. SecurityHeadersTests asserts the directive, so it changes too.
  • Vitest unit tests for the screen + any extracted helpers (house rule: every web/ change ships tests in the same PR)

Docs (same PR, house rule):

Guardrails

  • Financial history is never re-denominated: rows keep their own currency_code / currency_minor_unit snapshots forever (§4.6 row-level rule). Locale/currency edits must not touch stored rows.
  • Locale change side-effect: the locale's language component is step 2 of the UI-language resolution chain (§4.5) — changing farm locale can flip UI language for users without users.language. Needs a test once i18n infrastructure (API half): validation error codes + users.language + GET/PATCH /me #45 (i18n infra) lands.
  • Timezone change is forward-only: operational dates already stored as farm-local dates are not reinterpreted. (Spec is silent here — recording this as the decision.)
  • No cross-currency aggregation (Phase 1 single-currency per farm, §4.5).

Out of scope


Originally filed as Gitea issue #27 (https://gitea.mforcelabs.com/mforce/cluckwork/issues/27), now closed in favor of this one.

Activity

  1. changed the title [-]Farm settings — locale/timezone/currency editing + §4.6 currency-change guard (API + SPA)[/-] [+]Farm settings — profile (name/logo) + locale/timezone/currency editing + §4.6 currency-change guard (API + SPA)[/+] on Jul 21, 2026
  2. mforce commented on Jul 24, 2026

    @mforce
    OwnerAuthor

    Slice 3 shipped in #177 (merged as 4ed0a17), completing every Web and Docs box:

    • Farm settings screen (Setup → Farm settings, admin) — name, logo upload/preview/remove, locale, timezone, currency, unit system, first day of week, date/time format overrides
    • Farm name + logo in the sidebar branding slot, falling back to app branding
    • Currency field locked (read-only) with the §4.6 reason when financial rows exist — surfaced before the 422
    • One shared FarmProvider; settings changes reflect immediately
    • Date fields cap by farm-local today across all capture screens; duplicate todayIso() removed
    • SalesPage reads one minor unit (the order's)
    • CSP widened to img-src 'self' blob: with SecurityHeadersTests pinning the exact token set
    • Vitest coverage for the screen and the extracted helpers
    • Docs: GLOSSARY, specs §3.2, the Help page

    Landed on top of slice 3, also under this issue:

    • Configurable logo upload cap — 2 MB default under a fixed 5 MB schema ceiling, validated at startup, surfaced to the SPA
    • Square-mark upload guidance in the settings screen, Help and glossary

    Three review rounds (slice, its fix commit, the size change) across ~14 reviewer runs — codex + pi + two Claude agents each. Final on main: 273 / 70 / 319 .NET, 505 Vitest.

    Two follow-ups filed under this in epic #14:

    Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions