Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.jetbrains.annotations.NotNull;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.UUID;

Expand All @@ -17,10 +18,11 @@
* <p>
* Account resolution: the payer is always resolved as a personal account
* (created with starting balance if missing). The recipient is resolved by
* preferring its PERSONAL account — rental/sale income belongs to the
* landlord personally, even if they happen to own a firm. Only when the
* recipient has no personal account (a synthetic authority UUID backing a
* government entity) do we fall back to a government/business account.
* preferring its GOVERNMENT account, then PERSONAL, then BUSINESS — so
* government landlords (legacy DCGovernment-style real UUIDs that own both a
* personal and a government account) route income to their government
* treasury, while ordinary landlords still get their personal balance rather
* than a firm BUSINESS account they happen to own.
*/
public final class TreasuryEconomyProvider implements EconomyProvider {

Expand All @@ -47,10 +49,14 @@ public double getBalance(@NotNull UUID playerId) {
try {
Account payer = treasuryApi.resolveOrCreatePersonal(fromId);
Account recipient = resolveRecipientAccount(toId);
// Treasury rejects amounts with more than 2 decimal places. Amounts
// derived from arithmetic (e.g. pro-rata refunds: price * remaining /
// total) can carry extra precision, so normalise to 2 decimals here.
BigDecimal normalisedAmount = BigDecimal.valueOf(amount).setScale(2, RoundingMode.HALF_UP);
treasuryApi.transfer(new TransferRequest(
payer.getAccountId(),
recipient.getAccountId(),
BigDecimal.valueOf(amount),
normalisedAmount,
ledgerMessage,
fromId,
null,
Expand All @@ -74,27 +80,31 @@ public boolean hasLedgerSupport() {
}

/**
* Resolves the recipient's Treasury account.
* Resolves the recipient's Treasury account, preferring
* GOVERNMENT &gt; PERSONAL &gt; BUSINESS &gt; first-available.
* <p>
* A real player landlord always owns a PERSONAL account (Treasury enforces
* one per player), so we prefer it: their rental/sale income must land in
* their personal balance, never in a firm BUSINESS account they happen to
* own (firm accounts are owned by the proprietor's own UUID, which is how
* such funds previously leaked into business accounts).
* GOVERNMENT wins first because legacy government entities (e.g.
* DCGovernment) are real Minecraft accounts whose UUID owns <em>both</em> a
* personal and a government account; their leasehold income must land in
* the government treasury, not the player's personal balance.
* <p>
* Only when the recipient has no personal account — i.e. a synthetic
* authority UUID that backs a government entity — do we fall back to the
* prior government &gt; business &gt; first-available ordering so authority
* payments still route to the configured government treasury account.
* Ordinary landlords have no government account, so PERSONAL is chosen next:
* rental/sale income belongs to them personally, never a firm BUSINESS
* account they happen to own (firm accounts are owned by the proprietor's
* own UUID, which is how such funds previously leaked into business
* accounts).
* <p>
* When the recipient has no account at all, resolve-or-create their personal
* account.
*/
private @NotNull Account resolveRecipientAccount(@NotNull UUID ownerUuid) {
List<Account> accounts = treasuryApi.getAccountsByOwner(ownerUuid);
if (!accounts.isEmpty()) {
return accounts.stream()
.filter(a -> a.getAccountType() == AccountType.PERSONAL)
.filter(a -> a.getAccountType() == AccountType.GOVERNMENT)
.findFirst()
.or(() -> accounts.stream()
.filter(a -> a.getAccountType() == AccountType.GOVERNMENT)
.filter(a -> a.getAccountType() == AccountType.PERSONAL)
.findFirst())
.or(() -> accounts.stream()
.filter(a -> a.getAccountType() == AccountType.BUSINESS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ private int capturedDestination(UUID recipient) {
ArgumentCaptor<TransferRequest> req = ArgumentCaptor.forClass(TransferRequest.class);
verify(treasuryApi).transfer(req.capture());
assertEquals(payerPersonal.getAccountId(), req.getValue().fromAccountId());
assertEquals(BigDecimal.valueOf(50.0), req.getValue().amount());
// amount() is normalised to scale 2; compareTo is scale-insensitive.
assertEquals(0, new BigDecimal("50.00").compareTo(req.getValue().amount()));
return req.getValue().toAccountId();
}

Expand All @@ -72,6 +73,20 @@ void landlordWithFirm_routesToPersonalNotBusiness() {
"rent must land in the landlord's personal account, not their firm");
}

@Test
void legacyGovernment_withPersonalAndGovernmentAccount_routesToGovernment() {
UUID government = UUID.randomUUID();
// Legacy DCGovernment-style entity: a real Minecraft UUID that owns both
// a personal account (the original player) and the government account.
// Leasehold income must route to the government account, not personal.
when(treasuryApi.getAccountsByOwner(government)).thenReturn(List.of(
account(13, AccountType.PERSONAL, government),
account(9, AccountType.GOVERNMENT, government)));

assertEquals(9, capturedDestination(government),
"government landlord income must route to the government account, not personal");
}

@Test
void authorityUuid_withOnlyGovernmentAccount_routesToGovernment() {
UUID authority = UUID.randomUUID();
Expand Down