Skip to content

fix: admin "total balance" card must sum what its caption names - #258

Merged
argszero merged 1 commit into
mainfrom
fix/admin-total-balance-includes-gift
Sep 15, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/admin-total-balance-includes-gift

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The employees pane of the admin view renders a total-balance card whose own subtitle is
admin.emp.stats.total.sub"余额 + 赠送" (zh) / "balance + gift" (en) — printed directly
under the number. The card's value, however, only summed the permanent balance:

const total = users.reduce((a, u) => a + (u.balance || 0), 0);   // before

The same pane's table has a 可用 column (admin.emp.col.avail, caption admin.emp.list.sub =
「余额 / 赠送 / 可用(永久点数 + 每日赠送)」) computed as balance + gift_balance. So the card
under-reported the platform's spendable balance by exactly the total gift amount, and disagreed
with the column right below it.

Which direction is correct is fixed by the product, not by preference — so "weaken the caption so
the two agree" is not a fix:

  1. src/routes/wallet.rs returns available = balance + gift_balance; gift points are real money —
    they are spendable and they expire (src/gift.rs really moves them out of the account);
  2. the 「余额」 the user themself sees (sidebar #side-balance, dashboard dash.balance) is fed from
    exactly that field (loadSession: D.USER.balance = w.available);
  3. the table caption in the same pane documents the three columns as permanent / gift / available.

Repro (admin, empty accounts in one row is enough): open the admin view with a member who has
gift_balance != 0 — the card equals Σbalance while the 可用 column sums balance + gift_balance.

Fix

  • ui/js/app.js: the card's total includes u.gift_balance (one line + a comment pointing at the
    caption).
  • src/state_gate.rs: new static gate
    state_gate::tests::the_admin_total_balance_card_sums_what_its_caption_names, three rules with
    independent teeth:
    1. the card is unique in renderAdmin and its value argument is a plain identifier;
    2. the value's transitive closure (variable definitions in scope + the bodies of called helpers,
      comments stripped) must read both balance and gift_balance as identifier tokens
      equivalent rewrites are allowed (D.fmt(total + giftTotal), a helper, two per-component
      helpers), only "the sum dropped a component" is red;
    3. both language packs' subtitle still names both components in the same language.
      Plus discriminator self-tests (stat_value_argument segment extraction, assignment_statement
      attribution, caption_names_both_components) and a control that rule 2 only bites that one card
      (the token-usage bar row in the same function is not a stat(...) card).
  • ui/index.html: cache-bust app.js?v=20260915-13.
  • ui/README.md: contract note (caption = the card's statement of what the number is; a summary card
    must be sourced from the same definition as its caption).

Tests

  • Existing tests pass — cargo test 285 → 286 passed (one new gate test)
  • New tests added (the static gate + its self-tests)
  • cargo fmt --check clean; cargo clippy --all-targets unchanged (only the pre-existing
    src/protocol.rs:662 warning)

A/B on the gate (in-place mutations, byte-exact restore, md5 verified) — 9/9 legs as declared:

leg tree gate
M0 live (fixed) GREEN
M1 value back to balance only RED (rule 2)
M2 captions weakened in both packs RED (rule 3)
M3 card line removed RED (rule 1)
M4 separate gift total (equivalent rewrite) GREEN
M5 extracted into a helper (equivalent rewrite) GREEN
M6 helper that drops the gift (looks refactored, is wrong) RED (rule 2)
M7 axis reversed — sum gifts only RED (rule 2)
M8 competitor: caption weakened and value left balance-only RED (rule 2)

M7 and M8 are the competitor legs: the axis pushed the wrong way, and the "delete the promise"
half-fix. Both are rejected.

Behavioural probe (tmp/c2142_probe.js, jsdom, real ui/index.html + the four real scripts, only
fetch stubbed, drives the real admin session and real nav): 9/9 on the fixed tree; on the
pre-fix tree exactly the three axis legs (A1, A2, B2) fail — the card equals Σbalance while
the 可用 column sums balance + gift_balance; the probe also rejects the axis-reversed competitor.

Notes

  • No i18n keys added or removed (the caption already said both components; the number was what was
    wrong), so the i18n positive-control counts in src/i18n_pack.rs are untouched.
  • The visual/product bug is front-end only; the backend already returns both fields
    (src/routes/admin.rs selects balance, gift_balance).

The employees pane's total-balance card prints the subtitle
`admin.emp.stats.total.sub` ("余额 + 赠送" / "balance + gift") right under
the number, while its value summed `balance` only — so the card
under-reported the spendable total by the whole gift amount and disagreed
with the 可用 column directly below it (`balance + gift_balance`).

Gift points are real, spendable and expiring (`gift.rs`), and the product
defines the available balance as `balance + gift_balance` (`wallet.rs`;
`loadSession` feeds the sidebar/dashboard 「余额」 from `available`), so the
caption's formula is the correct one — the number, not the promise, is what
had to change.

- ui/js/app.js: include `u.gift_balance` in the card's total.
- src/state_gate.rs: new gate
  `the_admin_total_balance_card_sums_what_its_caption_names` — three rules
  with independent teeth (the card is unique and its value is a plain
  identifier; the value's transitive closure must read BOTH `balance` and
  `gift_balance` as identifier tokens; both packs' subtitle still names both
  components), plus discriminator self-tests for `stat_value_argument` /
  `assignment_statement` / `caption_names_both_components` and a control
  that rule 2 only bites that one card.
- ui/index.html: cache-bust `app.js?v=20260915-13`.
- ui/README.md: contract note for the card/caption agreement.

A/B (in-place mutations, byte-exact restore, md5 verified) 9/9 legs as
declared: live green; balance-only, captions weakened in both packs, card
removed, helper that drops the gift, axis reversed (gifts only) and the
"delete the promise" competitor all red; equivalent rewrites (separate gift
total, extracted helper, two per-component helpers) green. jsdom probe
`tmp/c2142_probe.js`: 9/9 on the fixed tree, axis legs red on the pre-fix
tree, and it rejects the axis-reversed competitor too.

cargo test 285 -> 286 passed. `cargo fmt --check` clean; clippy unchanged
(only the pre-existing `protocol.rs:662` warning).
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (Committer, self-merge enabled for this task — recorded as a PR comment because GitHub
rejects --approve on one's own PR).

What I verified

  • The axis is real and reproducible from the running product, not from reading the source: the jsdom
    probe boots the real ui/index.html + the four real scripts, stubs only fetch, drives a real admin
    session and a real nav click, and reads the rendered DOM. On the pre-fix tree the card reads 110
    while the 可用 column in the same pane sums 159.5; the caption printed right under the number says
    balance + gift.
  • Direction is decided by the product, not by preference: wallet.rs available = balance + gift_balance, gifts are spendable/expiring (gift.rs), and the balance the end user sees is
    available. So the number was wrong; weakening the caption would not be a fix — and the gate
    rejects that direction (M8).
  • Equivalent rewrites survive (M4/M5 plus the synthetic two_helpers case): the rule pins what was
    read
    , not how it was written.
  • Both competitor directions are rejected: axis reversed (M7 — sum gifts only) and delete-the-promise
    (M8). M7 is the reason rule 2 pins both components: a rule that pinned only gift_balance would
    have accepted it. Note contains("balance") cannot be used here — gift_balance ends with it — so
    the discriminator compares identifier tokens.
  • A/B harness restores every mutated file byte-exactly and re-checks md5 (verified after all 9 legs).
  • The gate cannot be satisfied by its own explanatory prose: the comment inside the fix mentions the
    caption key and gift_balance, and both the card-uniqueness rule (code_text_by_line blanks comment
    lines) and the closure rule (comments stripped) ignore it.

Scope

Frontend-only. No i18n keys added or removed (the caption already named both components), so the i18n
positive-control constants are untouched. The backend already returns balance and gift_balance.

Residual

The static gate is necessarily lexical: it proves the value's closure reads both fields, not that the
arithmetic is + (a hypothetical a + u.balance - u.gift_balance would pass rule 2). The probe covers
the arithmetic for the tree it runs on; CI has no JS runner, so this is the best available split and is
the same trade-off the neighbouring state_gate rules take.

@argszero
argszero merged commit 4af7cb7 into main Sep 15, 2026
1 check passed
@argszero
argszero deleted the fix/admin-total-balance-includes-gift branch September 15, 2026 02:34
@argszero argszero mentioned this pull request Sep 15, 2026
10 tasks
argszero added a commit that referenced this pull request Sep 15, 2026
Ships the 18 PRs merged since v0.7.24 (#242-#259). Schema 14 -> 15 (two
covering indexes, applied at startup). No config change, so no deployment-side
config.toml edit is needed.

Two themes:

Perf on the NFS dev database
- #259: stop mapping the db (PRAGMA mmap_size 64MB -> 0) and stop a real write
  per request (dao::touch_api_key gains a 60s guard). Measured on the live dev
  db: mmap=64MB 1.7-3.1s per COUNT / 250 MiB read vs mmap=0 ~10.5ms / 80 KiB;
  mmap=0 alone still leaves ~1.2s behind any write, so the pair is required.
- #242: codify the two emergency indexes in a v15 migration and gate the
  conditional joins at the plan level.
- #243: read the sharing page's earn total from one batched aggregate.

Frontend: display must equal what it filters on, and one fact, one source
- #250 one writer for the transaction cache; #251 clear every session slot at
  the identity boundary and give the wallet view a loader; #253 one shared
  writer for the wallet/dashboard month-changes; #254 boot loads only the
  destination view; #255 a model row's identity is the model, not its index;
  #256 the marketplace source follows the session, not whether data arrived;
  #257 the sidebar advertises only digits that work; #258 the admin
  total-balance card sums the gift amount its caption names.

i18n
- #249 every backend error reaches the wordlist, and the comment stripper stops
  mangling UTF-8; #252 the backend stops inventing Chinese display labels in
  response data fields.

Forms and robustness
- #244 a non-auth boot failure no longer looks like being logged out; #245 a
  credential 401 is no longer read as a session expiry; #246 wire timestamps
  reach the renderer unsliced; #247 inline cards submit from every field; #248
  a market row's availability label comes from that row.

- Cargo.toml / Cargo.lock: 0.7.24 -> 0.7.25.
- CHANGELOG.md: v0.7.25 entry.
- ui/index.html: cache-bust left as-is; the UI PRs in this release already
  advanced it past the value deployed with v0.7.24 (app.js 20260915-13,
  i18n.js 20260915-3).

cargo test 288 passed; cargo fmt --check clean; clippy unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant