Skip to content

Sales: per-farm discount ceiling, with Owner/Manager approval above it #727

Description

@mforce

Part of #719. Blocked by #720 (snapshot) and #721 (reason).

What

A per-farm maximum discount percent. Below it: allowed, reason required, visible.
Above it: a Sales user cannot confirm — the order stays Draft until an
Owner/Manager approves it.

Why this one is different

Every other slice in #719 reports. This is the only slice that prevents.
Everything else makes a discount visible after it has been given; this is the
only one that stops an unauthorised discount from becoming a confirmed sale.

Scope

  • MaxDiscountPercent on Account, beside WorkerSaleAllocationPolicy — the
    existing precedent for a per-farm policy setting on that aggregate. Nullable /
    unset means no ceiling, so existing farms are unaffected until an Owner sets one.
  • Settings UI for it, Owner-only.
  • ConfirmSaleHandler refuses a confirm from a Sales-role user when any line
    exceeds the ceiling; the order stays Draft.
  • Owner/Manager can confirm it. The role split already exists in exactly this
    shape — AuthPolicies.AdminOnly gates void and payment-void
    (src/Cluckwork.Api/Endpoints/Sales/PaymentEndpoints.cs:36) for the same
    reason.
  • The refusal names the ceiling and the offending line, so the seller knows what
    to ask for rather than retrying blindly.

Decide before writing code

  • Is "Sales cannot confirm, go find a manager" enough, or does this need a
    real in-app approval request/queue? EPIC: Discount visibility and control on sales #719 open question 3. Out-of-band is
    cheaper and probably right for the first cut, but say so deliberately — a
    half-built approval flow is worse than an honest refusal.
  • Ceiling per order or per line? Per line is stricter and harder to game by
    padding an order with at-list items.

Acceptance

  • A Sales user confirming an above-ceiling order is refused, and the order stays
    Draft.
  • An Owner/Manager can confirm the same order.
  • A farm with no ceiling set behaves exactly as today.
  • The refusal message names the ceiling and the offending line.
  • A line with a NULL list price cannot trip the ceiling (no list price, no
    measurable discount).

Repo rules

  • Daily entry: require grading to reconcile sellable eggs before submit #394 — write-contract change. A new confirm refusal is invisible to seeder
    tests that only reach the handler layer, and to tools/simulation/k6/ and the
    Playwright specs. Read those callers.
  • Version++ on the confirm path, with a parallel-race integration test.
  • Glossary + Help — the ceiling and the approval rule enter
    specs/product/GLOSSARY.md and the in-app glossary in the same PR.
  • i18n — the refusal message and Settings labels in all three locales.

Activity

  1. added this to the Phase 1.5 — Hardening milestone on Sep 8, 2026
  2. added
    sliceThin vertical work item
    epic-719Discount visibility and control on sales (epic #719)
    area:apiAPI/endpoint layer
    on Sep 8, 2026
  3. mforce commented on Sep 8, 2026

    @mforce
    OwnerAuthor

    Mockup

    #727 Ceiling and refusal

    Measured against its own precedent, this is bigger than it reads. Commit 0955095 ("feat: add configurable worker sale allocation (#619)") added one policy field to Account and touched 58 files: 7 docs/spec, 16 src, ~14 test, 1 Playwright spec, 9 web. A percent instead of an enum drops enums.ts but adds numeric range validation. Expect ~25 files, 2-3 days.

    The guard nobody remembers. BaseReferenceDataMigrationTests.cs:71-101 builds two HashSet<string> over Account's mapped properties, asserts an exact partition, then hard-codes the counts:

    Assert.Equal(10, accountComparedProperties.Count);
    Assert.Equal(8,  accountExcludedProperties.Count);

    A new Account property fails this until it is placed in one set and the literal is bumped. Find it with grep -rn "AssertExactMappedPropertyPartition" tests/, not by recall. (AccountIdConcurrencyTokenModelTests and FlockScopeDiscoveryTests use >= floors and are additive-safe by construction; this one uses ==, and that is why it bites.)

    UpdateFarmSettingsCommand is a whole-block positional replace including Version, so a new field is a required contract change on PUT /account/settings. Account.UpdateSettings is positional too, which makes CurrencyLockRaceTests.cs:65, CurrencyLockSerializationTests.cs:60 and SalesProductTests.cs:326 compile errors. FarmSettingsTests.cs:55 has a Body(…) helper with one optional param per setting — one line there covers ~15 call sites.

    The real risk is the enforcement, not the setting. ConfirmSaleHandler is 265 lines of carefully ordered locking (Account FOR SHARE → SalesOrder FOR UPDATE → fresh role read → FIFO lots FOR UPDATE). The check must read the policy off the already-locked account instance, and must sit after CheckCanConfirm() and after the role re-read, since the rule is role-conditional. It also needs a distinct error code and a 403-vs-422 decision, because the endpoint's MapFailure branches on Auth.Forbidden versus everything else.

    Two unstated requirements. GET /account/settings is AdminOnly, but SalesPage needs the ceiling to decide whether to warn — #612 solved the same problem for WorkerSaleAllocationPolicy by threading it through a different route; check how before designing. And this issue says Owner-only, while nav.tsx gates /settings on isAdmin = Owner or Manager. That is a new gate shape for that screen.

    A migration adding a column with a defaultValue also gets a dedicated migration test here — WorkerSaleAllocationPolicyMigrationTests.cs (78 lines, real Postgres, backfill + downgrade + re-upgrade) is the template.

  4. added
    size:LSeveral days; wide blast radius or unresolved scope
    on Sep 8, 2026
  5. mforce commented on Sep 8, 2026

    @mforce
    OwnerAuthor

    Correction: the body's "Owner-only" for the setting is wrong

    Checked against source. A Manager can already reach Farm settings, in the nav and at the API:

    • AuthPolicies.AdminOnly is RequireRole(Roles.Owner, Roles.Manager). The name is historic from F19: Admin-gate corrective/destructive actions — stepping stone to full RBAC #73 and does not mean Owner-only. OwnerOnly is the separate RequireRole(Roles.Owner) policy, reserved for user management.
    • GET and PUT /account/settings are both AdminOnly (AccountEndpoints.cs:26,31).
    • nav.tsx:79 gates /settings on isAdmin, with a comment saying it deliberately mirrors the API gate. AuthContext.tsx:22 defines isAdmin as Owner or Manager.

    This issue's body said Owner-only for the setting while its own title says Owner/Manager approval. The title is the right one.

    Recommendation: two separate gates, and only one of them is strict

    The ceiling value: Owner + Manager, the screen's existing gate. AuthPolicies defines Manager as the "corrective actions, config, money" tier, and a Manager can already void a confirmed order, void a payment and change the farm currency. A discount ceiling is smaller than all three.

    The mechanical argument is the stronger one. UpdateFarmSettingsCommand is a whole-block positional replace including Version, so the SPA sends every field on every save. A per-field Owner-only rule cannot be an endpoint policy; the handler would have to compare the incoming ceiling against the stored value and refuse when a Manager changed that one field. That is a new pattern on a screen with no mixed per-field permissions today.

    Confirming above the ceiling: this is where strictness belongs. It is a fresh role check inside ConfirmSaleHandler, which already does a role re-read inside the transaction, so an Owner-only rule there costs nothing extra. If the farm owner wants a hard "only I can approve a big discount", that is the lever, not the settings field.

    Net: drop "Owner-only" from the setting, keep the approval tier as a deliberate choice between Owner and Owner+Manager, and state which in the acceptance criteria.

  6. mforce commented on Sep 11, 2026

    @mforce
    OwnerAuthor

    Decisions taken before implementation, 2026-09-11

    Design record: docs/plans/727-discount-ceiling/01-design.md on feat/727-discount-ceiling.
    Synthesized from two independent design candidates; every fork below was decided
    deliberately, and the ones the owner ruled on are marked.

    The four open questions in the body, answered

    Question Decision Why
    Approval tier Owner + Manager (owner's call) Matches the title. A Manager can already void a confirmed order, void a payment and change the farm currency — a discount ceiling is smaller than all three. The correction comment above already withdrew "Owner-only" for the setting; this extends the same reading to the approval.
    Per line or per order Per line (owner's call) An order total is gameable by padding with at-list lines, which is the exact failure this epic exists to stop. Also what makes "the refusal names the offending line" meaningful.
    Approval flow Honest refusal, out of band (owner's call) The order stays Draft; the seller asks a Manager, who confirms it in the app. No queue, no request object, no new order state. #719 open question 3 answered: yes, "Sales cannot confirm, go find a manager" is enough.
    Settings gate AdminOnly (Owner + Manager) Per this issue's own correction comment. UpdateFarmSettingsCommand is a whole-block positional replace, so a per-field Owner-only rule would be a new mixed-permission pattern on a screen that has none.

    Amendment to acceptance criterion 5

    A line with a NULL list price cannot trip the ceiling (no list price, no measurable discount).

    This criterion is superseded. It was written 2026-09-08, one day before #720 shipped
    and replaced the single NULL with a four-value ListPriceBasis. SalesOrder.cs:304-308
    names #727 by number and says the opposite for one of those four values:

    for ProductUnpriced and NotComparable, "no comparable list price" is a RECORDED FACT
    and no discount is computable; for PreDating it means "we do not know", and the line may
    have been deeply discounted. Those two need opposite treatment.

    The replacement criterion, decided by the owner:

    ListPriceBasis At the ceiling Why
    Recorded Measured The list price is a captured fact.
    ProductUnpriced Never violates Recorded fact: the product had no list price, so nothing was discounted from anything.
    NotComparable Never violates Recorded fact: currency or minor unit did not match.
    PreDating Refused for a ceiling-bound actor We do not know. Fails closed.

    The hole this closes. A pre-#720 draft can still be re-priced to anything today. It
    carries no list price, so it does not trip HasBelowListLine, so #721 never asks for a
    discount reason — and if the ceiling skipped it too, a Sales or Worker user could give an
    unlimited discount with nothing recorded anywhere.

    The cost, bounded. The ListPriceBasis backfill relabelled every line older than
    2026-09-09 as PreDating, then DROPped the default, and nothing in the application ever
    writes that value — so the population can never grow and shrinks as those drafts are
    confirmed or voided. Confirmed pre-#720 orders are unaffected: they are not Draft, so
    they cannot be re-confirmed at all. An Owner or Manager confirms such an order untouched.

    ProductUnpriced and NotComparable are not a hiding place either: minting or unpricing a
    product is AdminOnly, i.e. exactly the tier that may exceed the ceiling anyway, so there
    is no privilege to gain.

    Shape, decided

    • CheckCanConfirm holds rules about the ORDER; the ceiling is a rule about the ACTOR.
      So the ceiling gets its own pure SalesOrder.CheckWithinCeiling(ceiling), called from
      ConfirmSaleHandler after the fresh in-transaction role read (Configurable Worker sales allocation scope by flock assignment #612) and before the
      EggLots lock. It is not folded into CheckCanConfirm, whose check order is pinned
      test-by-test and which Confirm() re-runs at mutation time.
    • 422, not 403. EggLot.AssignedFlocksInsufficientStock is a role-conditional refusal
      on this same handler and it is already a 422. 403 here means "your role cannot confirm
      sales orders at all"; Sales generically can. SaleEndpoints.cs needs no edit — the
      new codes fall into the existing else → 422.
    • Stored as int? MaxDiscountBasisPoints, plain nullable, no backfill. Compared
      cross-multiplied in Int128 ((list − unit) × 10 000 > bp × list), so nothing is ever
      divided and the boundary cannot disagree with itself. Exactly on the boundary is
      ALLOWED
      — "maximum 10%" means at most 10%. NULL is "no ceiling"; 0 is a legal,
      different setting meaning "no discount at all".
    • A Sales user sees the ceiling before confirming, via a derived per-caller
      yourMaxDiscountPercent on the role-agnostic GET /account — the established
      ShowFarmWideSaleAllocationNotice pattern. Withholding it buys nothing (the refusal
      discloses the same number on the first attempt) and costs the blind retries this issue
      exists to prevent. The SPA marks the breaching row live and disables Confirm, so the
      discount-reason dialog never opens for an order that is about to be refused.
    • The refusal's numbers render client-side. A server-composed sentence would print
      18.0% to a farm whose locale writes 18,0. The catalog key is parameter-free in
      en/es/tl; the English detail carries the numbers and names the line by egg grade for
      curl, k6, seeders, logs and tests.
    • Settings write stays on the plain optimistic path. The FOR UPDATE escalation exists
      for read-then-decide fields (currency probes whether money already moved); a ceiling
      decides nothing from elsewhere. The read side is already ordered by the Account
      FOR SHARE the confirm holds. This is asserted, not assumed: an integration test copied
      from SaleAllocationPolicyTests.ConfirmSale_ParksOnTheAccountLock_... with the ceiling in
      place of the policy. If it goes red, that decision was wrong.

    Deliberately out of scope

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

    area:apiAPI/endpoint layerarea:domainDomain layerarea:frontendReact/Vite web clientepic-719Discount visibility and control on sales (epic #719)size:LSeveral days; wide blast radius or unresolved scopesliceThin vertical work item

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions