Skip to content

fix: round a credit balance down rather than toward zero - #1343

Merged
sakibsadmanshajib merged 1 commit into
mainfrom
fix/console-balance-round-down
Aug 29, 2026
Merged

sakibsadmanshajib merged 1 commit into
mainfrom
fix/console-balance-round-down

Conversation

@sakibsadmanshajib

@sakibsadmanshajib sakibsadmanshajib commented Aug 29, 2026 •

Copy link
Copy Markdown
Owner

Closes #1344

Follow-up to #1336, which merged while this last fix was still in flight.

available_credits is posted minus reserved, so a workspace whose holds exceed its posted credits reads negative. formatUsdBalanceFromCredits rounded with Math.trunc, which rounds toward zero, so an overdrawn balance displayed less of the hole than was actually there. Math.floor is identical for every positive balance, which is every balance the demo box has today, and conservative for a negative one.

The docstring said "truncated toward zero"; it now states the direction it actually rounds, so the comment cannot drift from the code again.

Test

One case, in lib/format/format.test.ts: -8,295,000,000 credits renders -$8.30, not -$8.29. It fails on Math.trunc and passes on Math.floor, which was checked by reverting.

npx vitest run lib/format/format.test.ts tests/unit/credit-balance.test.tsx in Docker with --build on a private image tag: 30 passed.

No visual change on any surface that exists today, since every live balance is positive and Math.floor and Math.trunc agree there. The proof captures on #1336 stay accurate.

Buglog entry

{"date":"2026-08-29","area":"web-console","error_message":"A negative credit balance displayed less debt than the account carried","root_cause":"formatUsdBalanceFromCredits rounded with Math.trunc, which rounds toward zero, so an available balance driven negative by reservations was flattered by one displayed unit","fix":"Round with Math.floor, identical for positive balances and conservative for negative ones, and state the direction in the docstring","tags":["web-console","billing","credits","issue-1332"]}

available_credits is posted minus reserved, so a workspace whose holds exceed
its posted credits reads negative, and rounding toward zero showed less of the
hole than was there. Math.floor is identical for every positive balance and
conservative for a negative one.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 00db8c29-ca37-4006-864f-5e98c6a6abc3

📥 Commits

Reviewing files that changed from the base of the PR and between c2edee1 and 73798f3.

📒 Files selected for processing (2)
  • apps/web-console/lib/format/credits.ts
  • apps/web-console/lib/format/format.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sakibsadmanshajib
sakibsadmanshajib merged commit 2bad54d into main Aug 29, 2026
26 checks passed
@sakibsadmanshajib
sakibsadmanshajib deleted the fix/console-balance-round-down branch August 29, 2026 04:16
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
#1346)

Closes #1345

The chat composer banner and the Settings Usage tab printed the signed
in tenant's balance through `formatUsdFromCredits`, which is a port of
the console's PRICE formatter. A price rounds to nearest, which is right
for a published rate and wrong for a balance: 9,996,364,207 credits
rendered as `$10.00`, and the account does not hold ten dollars.

## What changed

`formatUsdBalanceFromCredits` is added to
`vendor/open-webui/src/lib/hive/credits.ts`, a port of the console
function of the same name in `apps/web-console/lib/format/credits.ts`,
carrying the rounding that function received in #1343: down, computed in
credit space rather than in dollars, never up and never toward zero.
Same name and same behaviour on purpose, so the next person reading the
two builds finds one rule rather than two.

Down rather than toward zero matters here for the same reason it
mattered on the console: `available = posted - reserved` has no clamp
(`apps/control-plane/internal/ledger/repository.go`), so an account
whose outstanding holds exceed its posted credits reads negative, and
rounding toward zero would show less of the hole than the account
carries.

The two balance call sites move onto it, `CreditsBanner.svelte`
(remaining) and `SettingsUsage.svelte` (organization credit balance).
Today's spend keeps `formatUsdFromCredits`, which is the split the
console already makes: spend and price figures go through the price
formatter, balances through the balance formatter. The old formatter's
docstring said it rendered a balance, which is how this defect got in;
it now says what it is for and points at the balance formatter for the
other case.

## The boundary disagreement

Flooring resolves the second half of the issue rather than moving it. A
floored figure can print `$0.50` only when the floored value is exactly
0.50 dollars, which requires at least 500,000,000 credits, which is
exactly `LOW_CREDITS_THRESHOLD`. So the number and the badge agree by
construction: 499,999,999 now renders `$0.499` beside the `low` state,
and the threshold itself renders `$0.50` beside `healthy`. The test pins
both sides of that boundary against the threshold constant rather than
against a literal, so a future credit unit rescale cannot quietly
separate them again.

## Test

Added to `vendor/open-webui/src/lib/hive/credits.test.ts`: the two
figures from the issue, the boundary pair above, the negative balance
case that mirrors the console's guard in
`apps/web-console/lib/format/format.test.ts`, zero, non finite, one
credit, and a property assertion that the rendered figure never exceeds
the real balance across eight magnitudes.

`settings-usage-tab.test.ts` moves its balance assertions onto the
balance formatter, and its fixture balance changes from 12,500,000,000
to 12,496,364,207, a value that renders `$12.49` through the balance
formatter and `$12.50` through the price formatter. Before that change
the two formatters produced identical strings for the fixture, so the
rendered assertion would have passed whichever one the component called.
Its empty balance case now expects `$0.00` rather than `$0`, which is
what the balance formatter renders for an exact zero and what the
console prints for the same account; the assertion's point, that an
empty balance is a readable dollar figure beside the badge rather than a
bare zero, is unchanged.

Runs, all through `sh scripts/test-owui-hive-frontend.sh` (Docker,
pinned node and the svelte version resolved from the vendored lockfile):

| Rounding in the new function | Result |
| --- | --- |
| `Math.round`, the ported price behaviour | 5 failed, 224 passed |
| `Math.floor`, this PR | 229 passed, 16 files, 14/14 components
compiled |
| `Math.trunc`, the bug #1343 fixed on the console | 1 failed (the
negative balance case), 228 passed |

The first row is the RED that came before the fix, the third is the
mutation check afterwards. Both were run, not reasoned about.

## Buglog entry

```json
{"date":"2026-08-29","area":"chat","error_message":"The chat composer credit banner displayed more money than the account held, and its text disagreed with its own low-credit badge at the threshold","root_cause":"vendor/open-webui/src/lib/hive/credits.ts rendered the balance with formatUsdFromCredits, a port of the console PRICE formatter, which rounds to nearest; 9,996,364,207 credits rendered as $10.00 and 499,999,999 rendered as the $0.50 threshold while creditState called the same balance low","fix":"Port the console balance formatter as formatUsdBalanceFromCredits, rounding down in credit space, and move the two balance call sites onto it while today's spend keeps the price formatter","tags":["chat","open-webui","billing","credits","issue-1345"]}
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved credit balance displays to round down, ensuring balances
never appear higher than the actual available amount.
* Updated usage and settings views to consistently show balances with
two decimal places, including `$0.00` for empty balances.
  * Preserved nearest-cent rounding for usage and spending figures.

* **Tests**
* Added coverage for rounding, zero, negative, invalid, and low-credit
balance scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
## What this fixes

Two defects found in a design review of the deployed box at SHA
`37d49fcf8`.

### 1. `$0.000000858` printed as money in permanent chrome

A balance of 858 credits rendered as `$0.000000858` in the chat credit
banner and again in Settings, Usage. Nine significant figures is the
exact width of one credit. That precision is right for a published
per-million catalog rate, where a real rate must never round to a
`$0.00` that reads as free, and it is wrong for a wallet: it carries
nothing a reader can act on, and it sat in chrome the customer cannot
dismiss.

Balances and spend now read in cents. An amount below one cent reads as
the bound `< $0.01`. That keeps the honesty invariant the nine decimals
existed for: a real, non-zero figure still never renders as the string
an empty wallet renders (`$0.00` for a balance, `$0` for spend).

The floor property established by PR #1343 and PR #1346 is unchanged and
re-pinned by a new property test: a displayed balance is never above the
real one, at any magnitude. Flooring is now done in whole cents rather
than in dollars, for the same float reason the previous code flooried in
credit steps, and it still moves away from zero for a negative balance,
so an account driven overdrawn by reservations overstates its hole
rather than flattering it.

### 2. The formatter existed in three copies and had already drifted
twice

The digit cap `Math.min(9, Math.max(2, 2 - magnitude))` lived at three
sites: `apps/web-console/lib/format/credits.ts:175`,
`vendor/open-webui/src/lib/hive/credits.ts:70`, and the same file at
line 116. All three are changed together. The console's fourth copy,
`apps/web-console/lib/format/model-pricing.ts:44`, is deliberately left
alone: that one formats a published catalog rate, where nine decimals is
the point.

`formatUsdBalanceFromCredits` is duplicated across two builds that
cannot share a module, and it has drifted twice already, as #1344 and
#1345, each time with both test suites green. Neither suite can catch
it: the console image copies `apps/web-console` and not `vendor/`, and
the chat image copies `vendor/open-webui` and not `apps/`.
`tools/lint-credit-balance-formatter-parity.mjs` is the first place both
files are read at once. It compares the shared declarations
(`CREDITS_PER_USD`, `SUB_CENT_BALANCE`, `formatUsdBalanceFromCredits`)
with comments, indentation, quote style and trailing commas normalised
away, so each build keeps its own formatter and commentary and nothing
else. It runs as its own required CI step next to the other
`tools/lint-*.mjs` checks.

### 3. Chat composer collided at 375px

Measured on the deployed box: `Cowork` occupied x124 to x199 and `Hive
Auto` occupied x145 to x272. Fifty-four pixels of overlap, with the two
labels printing on top of each other, leaving Cowork unreadable and
unreliably tappable on a phone.

The composer's left group was `flex-1 min-w-0`, so it shrank to whatever
the model chip and send controls left it. Its own children do not
shrink: the plus button is `shrink-0` and `.hv-mode` is `flex-shrink: 0`
in `hive.css`. Shrinking the group therefore moved its content out of
the box rather than making it narrower, and the overflow painted over
the model chip.

The row now wraps. Below `sm` the left group takes the whole line and
the model chip and send controls wrap under it; from `sm` up the flex
basis returns to 0 and the layout is exactly what it was, with
`flex-wrap` left in place so any future overflow lands on a second line
instead of on top of the chip. Wrapping rather than shrinking, because
nothing here can shrink honestly: truncating a two-word mode label to
"Cow..." is a broken control, not a smaller one, and the labels are
translated, so no width is safe to assume.

## Both build systems are exercised

Item 1 touches both. The console side is
`apps/web-console/lib/format/credits.ts`, verified with `docker compose
run --build web-console npm run test:unit` (903 tests pass) and `npm run
build`. The chat side is `vendor/open-webui/src/lib/hive/credits.ts`,
verified with `scripts/test-owui-hive-frontend.sh` (234 tests pass, 14
of 14 components compile). Both files touched here are inside that
script's scope: `src/lib/hive` is copied recursively, and
`chat/MessageInput.svelte` is on its explicit component list, so the
guard added to `composer-size-guard.test.ts` reads the file the deploy
actually builds.

One pre-existing failure is unrelated and not introduced here:
`apps/web-console/tests/unit/ci-web-e2e-secret-free.test.ts` reads
`.github/workflows/ci.yml`, which `Dockerfile.web-console` does not copy
into the image, so it cannot pass in that container regardless of this
change.

## Tests that can fail

Each was mutated and confirmed RED before being trusted.

- Parity lint: changing `Math.floor` to `Math.round` in the chat copy
alone exits 1 and names the divergence. Reverted, exits 0.
- Console money tests: changing the load-bearing `CREDITS_PER_USD / 100`
step turns 7 of 28 assertions red, including the floor property and both
sub-cent cases.
- Composer guard: reverting the left group's class list to `flex-1
min-w-0` turns the guard red.

Worth recording, because it is the shape of a test that cannot fail:
mutating `maximumFractionDigits` from 2 to 9 alone changes nothing,
because the flooring to whole cents happens before the number reaches
`Intl.NumberFormat`. The digit cap is a belt-and-braces guard, not the
mechanism, and the tests are pinned to the mechanism.

## Buglog entry

```json
{"id":"bug-1349-credit-precision-composer-overlap","date":"2026-08-29","title":"Balance rendered nine significant figures and the composer toolbar overlapped at 375px","error_message":"$0.000000858 shown as a credit balance in the chat banner and Settings > Usage; Cowork (x124-199) and Hive Auto (x145-272) drawn on top of each other at 375px","root_cause":"Two unrelated causes. (1) formatUsdBalanceFromCredits and the chat spend formatter both scaled decimal places to the value with Math.min(9, Math.max(2, 2 - magnitude)), a rule ported from the catalog PRICE formatter where nine decimals is correct, so a sub-cent balance rendered at the full width of one credit in chrome the customer cannot dismiss. The rule was hand-duplicated across two builds whose test suites cannot see each other, which is also how #1344 and #1345 happened. (2) The composer control row's left group was flex-1 min-w-0 while its children were shrink-0 and .hv-mode was flex-shrink: 0, so shrinking the group pushed its content out of the box and over the model chip.","fix":"Format balances and spend in whole cents, floored for balances and rounded for spend, with a sub-cent bound of \"< $0.01\" so a real non-zero figure still never renders as the empty-wallet string. Added tools/lint-credit-balance-formatter-parity.mjs, wired as a CI step, to fail the build when the two copies of formatUsdBalanceFromCredits diverge. Made the composer control row wrap, with the left group taking a full line below sm and the previous basis restored from sm up.","tags":["money","formatting","frontend","open-webui","web-console","layout","responsive","cross-build-duplication"]}
```
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
## Summary

This is the batched buglog follow-up for the pull requests merged to
`main` on 2026-08-29. Its diff is `.wolf/buglog.jsonl` and nothing else.

Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or
failed build must be logged, but the line may never be appended on a fix
branch. `merge=union` in `.gitattributes` resolves concurrent appends
locally and is ignored by GitHub's server side merge, so two branches
that both appended land in hard conflict there. An unmergeable pull
request gets no `refs/pull/N/merge`, no `pull_request` run and therefore
zero checks, and the required status gate then blocks the merge for a
reason the page never states (issue #873). Each fix accordingly carried
its entry in its own pull request body, and this pull request copies
them onto `main` in one batch, which the protocol explicitly prefers
over one pull request per entry.

## Scope examined

Fifty nine pull requests merged to `main` on 2026-08-29. Forty eight of
them carried at least one entry, for eighty two entries in total. Thirty
two of those were already on `main` and are skipped, leaving fifty
appended here from thirty four pull requests.

The largest block of skips comes from #1342, the equivalent batch for
the 2026-08-28 merges, which merged earlier the same day and already
landed thirty six entries covering #1257, #1268, #1276, #1277, #1287,
#1292, #1293, #1294, #1296, #1301, #1303, #1305, #1313, #1335 and #1337.

## What landed

Fifty entries appended, one JSON object per line, append only. The 232
pre-existing lines are byte identical to `origin/main` (verified by
hashing the first 232 lines of the result against the base file). Every
line in the resulting file parses as JSON and carries `error_message`,
`root_cause`, `fix` and `tags`.

| Source | Entries |
|---|---|
| #1083 | 2 |
| #1277 | 1 |
| #1278 | 1 |
| #1298 | 1 |
| #1334 | 1 |
| #1336 | 3 |
| #1343 | 1 |
| #1346 | 1 |
| #1351 | 1 |
| #1365 | 2 |
| #1368 | 1 |
| #1369 | 1 |
| #1371 | 3 |
| #1375 | 3 |
| #1376 | 1 |
| #1378 | 1 |
| #1379 | 2 |
| #1388 | 5 |
| #1389 | 3 |
| #1390 | 2 |
| #1393 | 1 |
| #1394 | 1 |
| #1410 | 1 |
| #1417 | 1 |
| #1421 | 1 |
| #1423 | 1 |
| #1424 | 1 |
| #1426 | 1 |
| #1429 | 1 |
| #1431 | 1 |
| #1433 | 1 |
| #1434 | 1 |
| #1436 | 1 |
| #1439 | 1 |

Entries are copied verbatim from their source pull request bodies.
Nothing was rewritten, no field was invented, and no field was added. No
JSON needed repair: all eighty two extracted entries parsed on the first
attempt and all four required fields were present on every one.

## Merged pull requests that carried no entry

Eleven of the fifty nine. Recorded here because the gap is itself the
useful signal.

| Pull request | Title | Assessment |
|---|---|---|
| #1013 | chore(deps): bump the go-minor-patch group across 1 directory
with 4 updates | Dependabot bump, no defect fixed, no entry expected |
| #1015 | chore(deps): bump the go-minor-patch group across 1 directory
with 6 updates | Dependabot bump, no entry expected |
| #1016 | chore(deps): bump golang from 1.26-alpine to 1.27-alpine in
/deploy/docker | Dependabot bump, no entry expected |
| #1218 | chore(deps): bump postcss from 8.5.19 to 8.5.26 in
/apps/desktop | Dependabot bump, no entry expected |
| #1219 | chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in
/apps/control-plane | Dependabot bump, no entry expected |
| #1342 | chore: batch buglog entries for the 2026-08-28 merges | The
previous batch pull request itself, correctly carries no entry of its
own |
| #1364 | chore: remove four dead skills and record the patterns that
cost time | Protocol gap. The body records patterns that cost time,
which is the shape of a buglog entry, but none was written as one |
| #1383 | test: retire stale expected-failure markers, restore the ones
that are true (#1381, #1382, #1324) | Protocol gap. Stale `it.fails`
markers reading as red is a real defect that was fixed here and should
have carried an entry |
| #1384 | docs: correct D-047, hive-auto reverted to variable pricing
(D-059) | Decision ledger correction, arguably a documentation defect,
no entry written |
| #1387 | chore(deps): bump next from 15.5.23 to 16.3.3 in
/apps/agent-console | Dependabot bump, no entry expected |
| #1398 | docs: rescue the 2026-08-25 parity captures and add the
2026-08-29 QA matrix evidence | Documentation and evidence rescue, no
entry written |

Six of the eleven are Dependabot bumps and one is the previous batch, so
the genuine protocol gaps are #1364, #1383, #1384 and #1398. Of those,
#1383 is the one worth a follow-up: it fixed a real defect class (a
stale expected-failure marker reads as a red "Expect test to fail" and
gets dismissed as pre-existing) and left no record.

## Entries skipped as already present

Thirty two. Thirty of them matched an entry already on `main` on
`error_message`, `id` or `fix`. Two more from #1278 are semantic
duplicates that an exact match would have missed, and were skipped after
reading the landed entries they duplicate:

- #1278's `streaming content_block_start omits text field` entry is
covered by the consolidated
`bug-2026-08-28-anthropic-sdk-wire-conformance` entry landed from #1296,
whose root cause names the same `omitempty` on
`StreamContentBlock.Text`.
- #1278's `GET /v1/models leaked an upstream provider name` entry is
covered by `BUG-1284`, landed from #1300, which names the same
`public.model_aliases.summary` publication path.

#1278's third entry, on `top_k` forwarding producing a 400, is not
covered anywhere on `main` and is appended here. #1342 recorded #1278 as
fully "merged into #1296", which was accurate for two of its three
entries.

## Note on entry quality

One appended entry is thin: #1277's parity re-score record carries
`error_message` of `n/a` and a root cause of "console had no
privacy/data-policy surface at all". It is a parity gap record rather
than a defect record. It is included exactly as written rather than
embellished, per the protocol's preference for the author's own words.

## Test plan

- [x] Branch cut fresh from `origin/main`, diff is `.wolf/buglog.jsonl`
and nothing else
- [x] First 232 lines byte identical to the base file (md5 match)
- [x] All 282 resulting lines parse as JSON and carry `error_message`,
`root_cause`, `fix` and `tags`
- [x] No `.wolf/` telemetry (`anatomy.md`, `memory.md`,
`token-ledger.json`, `hooks/_session.json`, `buglog.json`) in the commit
- [ ] The six required checks report green via the inert path allowlist
in `.github/workflows/ci.yml`

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

Console rounds a negative credit balance toward zero, understating an overdrawn account

1 participant