fix(wallets): serve USDT balances as fractional cents — whole-cent rounding overstated spendable balance - #480
Merged
Merged
Conversation
…unding overstated spendable balance The wallet balance field is typed FractionalCentAmount, but the USDT branch (every post-cutover cash wallet) rounded micros to whole cents half-to-even before serving — reporting up to half a cent MORE than the wallet holds. Any client that sent its reported balance failed at IBEX: 'insufficient balance. Current Balance: 1.099346 ... invoice amount: 1.100000' (found on-device while testing the mobile MAX button: 1,099,346 micros served as 110 cents). usdtMicrosToUsdCents moves to cash-wallet-cutover/amount-conversion (pure bigint, alongside its Ceil sibling; the graphql module home booted app infra on import, hanging unit tests on redis retries) and now preserves the fraction (≤4 dp). Both UsdWallet and UsdtWallet balance resolvers route through it. asUsdCents()'s whole-cent default is untouched — payment paths are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
…, resolver-seam pin - usdtMicrosToUsdCents now handles signed input: FractionalCentAmount is documented signed and IBEX can report small negative balances (fee reconciliation); the previous hard-error would have broken the wallet's balance field on every query. A leading '-' negates the result; -0 normalizes to 0. - The function returns number | InvalidCashWalletCutoverAmountError like its siblings instead of throwing bare/raw errors; both UsdWallet and UsdtWallet balance resolvers throw mapError(err) like their other error paths, so bad input surfaces as a mapped INVALID_INPUT, not an unmapped internal error. - Pin the twice-regressed resolver seam (#230 USD; this PR USDT) with a real GraphQL query over the real UsdtWallet type (@app mocked, real amount-conversion): 1,099,346 micros serves 109.9346 on the wire, whole cents serve the integer, negatives serve signed, malformed balance maps through mapError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
The UsdtWallet seam pin cannot see a UsdWallet-only regression, and the legacy-wallet query is the path every post-cutover client still hits: resolveCashWalletPresentationForAccount redirects the legacy USD wallet id to the USDT settlement wallet inside UsdWallet's balance resolver. This exact field has regressed twice (#230, this PR's bug). Real GraphQL query over the real UsdWallet type, presentation mocked to redirect, conversion real: 1,099,346 micros on the settlement wallet must serve 109.9346 on the wire, and the balance lookup must land on the settlement wallet id. Verified by mutation: reverting the resolver branch to Number(balance.asUsdCents()) fails the test with 110. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
…cksum verification carvel-dev/setup-action@v1 verifies downloads against a '<sha256> ./<file>' line in the release notes; ytt v0.55.2's notes use a single space, so every Quickstart and UAT smoke run fails in seconds at setup. Pin to v0.55.1 (correct two-space format) until an upstream release restores it, then drop the pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
bobodread876
approved these changes
Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The wallet
balanceGraphQL field is typedFractionalCentAmount, but the USDT branch — which every post-cutover cash wallet hits — rounded micros to whole cents (half-to-even) before serving. The API reported up to half a cent more than the wallet holds, so any client that sent its reported balance died at IBEX:Found on-device while testing flash-mobile's MAX button (#512 / flash-mobile#689): a wallet holding 1,099,346 micros ($1.099346) was served
balance: 110, the app filled $1.10, and the send failed. The legacy USD branch already preserves fractions (asCents(8), #230) — the USDT branch regressed the field's contract.Fix
usdtMicrosToUsdCentsmoves tocash-wallet-cutover/amount-conversion.ts(pure bigint, alongsideusdtMicrosToUsdCentsCeil) and now preserves the fraction (1 cent = 10,000 micros, ≤4 dp). Its old home in the graphql module booted app infra on import, which also hung unit tests on Redis retries.UsdWalletandUsdtWalletbalance resolvers route through it.USDTAmount.asUsdCents()whole-cent behavior is untouched — payment paths are unaffected. No schema change (field was alreadyFractionalCentAmount).Clients floor for spendable-amount decisions — flash-mobile#689's MAX already does (it floors to whole minor units), so once this deploys the device repro passes. flash-mobile#690 tracks display-side flooring.
Tests
New
usdtMicrosToUsdCentssuite inamount-conversion.spec.ts: the exact device-repro pin (1,099,346 → 109.9346), the half-to-even cent boundary (109.5 stays 109.5), whole cents, full 4-dp precision, bigint/number inputs,.00tolerance, fractional-micro and malformed-input rejection. Suites: cutover + wallets dirs 18/18, 129 tests green;tsc-checkand eslint clean.🤖 Generated with Claude Code
https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH