Skip to content

fix: make government account resolution symmetric across transfers and balance checks - #27

Merged
md5sha256 merged 3 commits into
mainfrom
fix/government-account-symmetry
Aug 22, 2026
Merged

fix: make government account resolution symmetric across transfers and balance checks#27
md5sha256 merged 3 commits into
mainfrom
fix/government-account-symmetry

Conversation

@md5sha256

Copy link
Copy Markdown
Collaborator

Follow-up to #17. Branched from main, independent of the notification-adapter work.

Problem

#17 made the recipient side of TreasuryEconomyProvider.transfer prefer a party's GOVERNMENT account over their PERSONAL one, so rent from a government-owned leasehold lands in the government treasury.

The paying side was never changed. transfer still resolved the payer with treasuryApi.resolveOrCreatePersonal(fromId) unconditionally, so when the government is the payer the money came out of the entity's personal balance instead of the treasury the rent went into. That happens on every lease-termination refund, which is landlordId → tenantId:

  • Realty.java:509 — scheduled terminations refunding prepaid-but-unused time
  • RealtyPaperApiImpl.java:483 — the defensive refund when the backend update fails

Net effect: renting a government region credits the treasury, un-renting it debits the personal account. The treasury only ever grows.

getBalance had the same asymmetry from the other direction — it used getBalanceByOwnerUuid, which does not apply the preference at all, so an affordability check on a government entity inspected a different account than the payment it gates would touch.

Fix

One preference, applied everywhere: GOVERNMENT > PERSONAL > BUSINESS > first-available.

  • Extracted preferredAccount(List<Account>), returning empty when the party holds no accounts.
  • transfer resolves both payer and recipient through resolveAccount, which falls back to resolveOrCreatePersonal when there are none.
  • getBalance uses the same preference and reads getBalanceByAccountId. It keeps its no-account-means-zero contract and deliberately has no create-if-missing fallback — a balance read must not have the side effect of opening an account.

Ordinary players are unaffected: with no government account they still resolve to PERSONAL, and a firm proprietor still pays from their personal balance rather than a BUSINESS account owned by their own UUID (the leak #17 originally fixed).

Tests

Written before the fix; all failed first, all pass now. TreasuryEconomyProviderTest: 11 tests, 0 failures, full :realty-paper:test green.

New payer-side coverage mirroring the existing recipient-side cases:

  • governmentPayer_refundIsDebitedFromGovernmentNotPersonal
  • firmProprietorPayer_paysFromPersonalNotBusiness
  • payerWithNoAccounts_resolvesOrCreatesPersonal

New balance coverage:

  • governmentBalance_readsTheGovernmentAccountNotPersonal
  • firmProprietorBalance_readsPersonalNotBusiness
  • balanceWithNoAccounts_isZeroAndCreatesNothing — asserts resolveOrCreatePersonal is never called
  • balanceOfNull_isZero

The existing recipient-side test helper was reworked to stub the payer through getAccountsByOwner rather than resolveOrCreatePersonal, matching the new resolution path.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SUskvuegwBzQMufaYxp5eM

md5sha256 and others added 3 commits August 23, 2026 00:19
PR #17 made recipient resolution prefer a party's GOVERNMENT account over
their PERSONAL one, so rent from a government-owned leasehold lands in the
government treasury. The paying side was never changed: transfer() still
resolved the payer with resolveOrCreatePersonal, so when the government is
the payer -- a lease-termination refund, landlord -> tenant -- the money
came out of the entity's personal balance instead of the treasury the rent
went into.

Resolve both sides through the same GOVERNMENT > PERSONAL > BUSINESS >
first-available lookup. Ordinary players are unaffected: with no government
account they still resolve to PERSONAL, and a firm proprietor still pays
from their personal balance rather than a BUSINESS account they own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUskvuegwBzQMufaYxp5eM
getBalance still went through getBalanceByOwnerUuid, which does not apply
the GOVERNMENT > PERSONAL > BUSINESS preference the two sides of transfer()
now share. An affordability check on a government entity therefore inspected
its personal balance while the payment itself would debit the treasury.

Extract the preference into preferredAccount(List<Account>) and have both
getBalance and resolveAccount use it, so a balance check and the transfer it
gates can never disagree about which account is in play. getBalance keeps its
no-account-means-zero contract and deliberately has no create-if-missing
fallback -- a read must not open an account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUskvuegwBzQMufaYxp5eM
Government account resolution is now symmetric across transfers and balance
checks; a bugfix release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUskvuegwBzQMufaYxp5eM
@md5sha256
md5sha256 merged commit 495e18f into main Aug 22, 2026
1 check failed
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