You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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'scurrencyMinorUnit (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.
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.
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
Multi-currency / exchange-rate conversion (not in Phase 1)
Currency-migration workflow for farms with existing transactions (spec: new farm record or future workflow)
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.
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+PATCHfarm settings endpoint (admin-gated), covering:name,locale,timezone,currency_code,unit_system,first_day_of_week,date_format_override,time_format_override(namealready exists in §3.2; the rest per §4.5)byteacolumn onaccounts: 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. Stillbytea, still one database to back up — in its ownfarm_logostable, with a bytes-free projection for "is there one, and which".ANMFframe nests its own chunk stream a flat allowlist cannot sweep) and caps every declared dimension at 4096. Known limit, stated in the code:IDAT,iCCPand a JPEG ICCAPP2are variable-length by nature and can carry arbitrary bytes — no non-decoding sanitizer can prevent that.currency_codechange with a 422 + stable machine-readablecodeonce anysales_orders,payments, orexpensesrow exists for the farm; allowed when none existcurrency_symbol+currency_minor_unitfrom the static ISO 4217 lookup; fallbacks per §4.5 (symbol = code, minor unit = 2)timezone,locale, andcurrency_codeversiontoken) on the farm aggregate; mismatch → 409Web (SPA):
farm.locale+ currency fields +farm.timezone(§4.5 display rule) — settings changes reflect immediately after savetodayIso()is browser-local and is used as both themaxand the initial value on every date input: Daily entry, flock placement (create + edit), bird movements, expenses (and its month picker), inventory purchase/usage, Reportsto, 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. Feedfarm.timezonefrom this slice's settings payload into one shared helper, and drop the duplicate localtodayIso()definitions inInventoryPage.tsx:25andSalesPage.tsx:19(the shared one issrc/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.SalesPage.tsxprefills the price by dividing the product's default by the product'scurrencyMinorUnit(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'sSalesOrder.ProductPriceCurrencyMismatchguard 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.img-src 'self' blob:. Security: forwarded headers, HSTS, CSP + security headers, pinned AllowedHosts #144/F144: forwarded proto, HSTS, CSP + security headers, pinned AllowedHosts #160 shippedimg-src 'self', which blocksblob: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.SecurityHeadersTestsasserts the directive, so it changes too.web/change ships tests in the same PR)Docs (same PR, house rule):
farm_logostable and why it is not afarmscolumn (PR Farm logo: upload, serve and remove, with a no-decode image sanitizer (#123) #174)Guardrails
currency_code/currency_minor_unitsnapshots forever (§4.6 row-level rule). Locale/currency edits must not touch stored rows.users.language. Needs a test once i18n infrastructure (API half): validation error codes + users.language + GET/PATCH /me #45 (i18n infra) lands.Out of scope
Originally filed as Gitea issue #27 (https://gitea.mforcelabs.com/mforce/cluckwork/issues/27), now closed in favor of this one.