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
1 change: 1 addition & 0 deletions realty-paper/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies {
implementation("org.spongepowered:configurate-yaml:4.2.0")

testImplementation("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT")
testImplementation("net.democracycraft:treasury-api:2.0.0")
testImplementation("org.mockito:mockito-core:5.15.2")
testImplementation("org.mockito:mockito-junit-jupiter:5.15.2")
testImplementation("com.github.MilkBowl:VaultAPI:1.7") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
* in the player's Treasury transaction history.
* <p>
* Account resolution: the payer is always resolved as a personal account
* (created with starting balance if missing). The recipient is resolved
* by preferring any non-personal account (government/business) owned by
* that UUID, falling back to a personal account. This correctly handles
* authority UUIDs that correspond to government Treasury accounts.
* (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.
*/
public final class TreasuryEconomyProvider implements EconomyProvider {

Expand Down Expand Up @@ -73,18 +74,28 @@ public boolean hasLedgerSupport() {
}

/**
* Resolves the recipient's Treasury account. Prefers a government or business
* account when one exists (e.g. for authority/landlord UUIDs tied to a town
* or government entity), falling back to a personal account.
* Resolves the recipient's Treasury account.
* <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).
* <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.
*/
private @NotNull Account resolveRecipientAccount(@NotNull UUID ownerUuid) {
List<Account> accounts = treasuryApi.getAccountsByOwner(ownerUuid);
if (!accounts.isEmpty()) {
// Prefer government > business > personal so that authority accounts
// correctly route funds to the configured government treasury account.
return accounts.stream()
.filter(a -> a.getAccountType() == AccountType.GOVERNMENT)
.filter(a -> a.getAccountType() == AccountType.PERSONAL)
.findFirst()
.or(() -> accounts.stream()
.filter(a -> a.getAccountType() == AccountType.GOVERNMENT)
.findFirst())
.or(() -> accounts.stream()
.filter(a -> a.getAccountType() == AccountType.BUSINESS)
.findFirst())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class RealtyPaperApiImplTest {
@BeforeEach
void setUp() {
signCache = new SignCache();
ExecutorState executorState = new ExecutorState(Runnable::run, sameThreadExecutorService());
ExecutorState executorState = new ExecutorState(Runnable::run, sameThreadExecutorService(), sameThreadExecutorService());
api = new RealtyPaperApiImpl(realtyApi, economyProvider, executorState, database,
regionProfileService, signTextApplicator, signCache);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package io.github.md5sha256.realty.economy;

import net.democracycraft.treasury.api.TreasuryApi;
import net.democracycraft.treasury.model.economy.Account;
import net.democracycraft.treasury.model.economy.AccountType;
import net.democracycraft.treasury.model.economy.TransferRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

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

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class TreasuryEconomyProviderTest {

@Mock
private TreasuryApi treasuryApi;

private TreasuryEconomyProvider provider;

private final UUID payer = UUID.randomUUID();

@BeforeEach
void setUp() {
provider = new TreasuryEconomyProvider(treasuryApi);
}

private Account account(int id, AccountType type, UUID owner) {
Account a = new Account();
a.setAccountId(id);
a.setAccountType(type);
a.setOwnerUuid(owner);
return a;
}

private int capturedDestination(UUID recipient) {
Account payerPersonal = account(1, AccountType.PERSONAL, payer);
when(treasuryApi.resolveOrCreatePersonal(payer)).thenReturn(payerPersonal);
when(treasuryApi.transfer(any())).thenReturn(99L);

PaymentResult result = provider.transfer(payer, recipient, 50.0, "Rental Payment: REGION");
assertInstanceOf(PaymentResult.Success.class, result);

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());
return req.getValue().toAccountId();
}

@Test
void landlordWithFirm_routesToPersonalNotBusiness() {
UUID landlord = UUID.randomUUID();
// Landlord is a firm proprietor: owns both their PERSONAL account and a
// firm BUSINESS account (which is owned by their own UUID).
when(treasuryApi.getAccountsByOwner(landlord)).thenReturn(List.of(
account(500, AccountType.BUSINESS, landlord),
account(42, AccountType.PERSONAL, landlord)));

assertEquals(42, capturedDestination(landlord),
"rent must land in the landlord's personal account, not their firm");
}

@Test
void authorityUuid_withOnlyGovernmentAccount_routesToGovernment() {
UUID authority = UUID.randomUUID();
// Synthetic authority/government entity: no personal account exists.
when(treasuryApi.getAccountsByOwner(authority)).thenReturn(List.of(
account(7, AccountType.GOVERNMENT, authority)));

assertEquals(7, capturedDestination(authority),
"authority payments must still route to the government account");
}

@Test
void recipientWithNoAccounts_resolvesOrCreatesPersonal() {
UUID newOwner = UUID.randomUUID();
when(treasuryApi.getAccountsByOwner(newOwner)).thenReturn(List.of());
when(treasuryApi.resolveOrCreatePersonal(newOwner))
.thenReturn(account(88, AccountType.PERSONAL, newOwner));

assertEquals(88, capturedDestination(newOwner));
}
}