diff --git a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts index 7849c56..914085c 100644 --- a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts @@ -10,6 +10,7 @@ val targetJavaVersion = 21 java.toolchain.languageVersion.set(JavaLanguageVersion.of(targetJavaVersion)) repositories { + mavenLocal() mavenCentral() maven { name = "papermc-repo" @@ -27,6 +28,10 @@ repositories { name = "essentialsx" url = uri("https://repo.essentialsx.net/releases/") } + maven { + name = "paradaux-snapshots" + url = uri("https://repo.paradaux.io/snapshots") + } } dependencies { diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/entity/PlotOwnerCount.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/entity/PlotOwnerCount.java new file mode 100644 index 0000000..41a0f5f --- /dev/null +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/entity/PlotOwnerCount.java @@ -0,0 +1,5 @@ +package io.github.md5sha256.realty.database.entity; + +import java.util.UUID; + +public record PlotOwnerCount(UUID titleHolderId, int plotCount) {} diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/FreeholdContractMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/FreeholdContractMapper.java index baeedd2..f720abb 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/FreeholdContractMapper.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/FreeholdContractMapper.java @@ -1,9 +1,11 @@ package io.github.md5sha256.realty.database.mapper; import io.github.md5sha256.realty.database.entity.FreeholdContractEntity; +import io.github.md5sha256.realty.database.entity.PlotOwnerCount; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.List; import java.util.UUID; /** @@ -144,4 +146,10 @@ int atomicBuy(@NotNull String worldGuardRegionId, double averagePrice(); + /** + * Returns the number of freehold plots owned (as title holder) by each player. + * Only players with at least one plot are included. + */ + @NotNull List selectPlotCountsByTitleHolder(); + } diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaFreeholdContractMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaFreeholdContractMapper.java index e9754a9..232663c 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaFreeholdContractMapper.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaFreeholdContractMapper.java @@ -1,6 +1,7 @@ package io.github.md5sha256.realty.database.maria.mapper; import io.github.md5sha256.realty.database.entity.FreeholdContractEntity; +import io.github.md5sha256.realty.database.entity.PlotOwnerCount; import io.github.md5sha256.realty.database.mapper.FreeholdContractMapper; import org.apache.ibatis.annotations.Arg; import org.apache.ibatis.annotations.ConstructorArgs; @@ -10,6 +11,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.List; import java.util.UUID; @@ -202,4 +204,17 @@ SELECT COALESCE(AVG(price), 0) """) double averagePrice(); + @Override + @Select(""" + SELECT titleHolderId, COUNT(*) AS plotCount + FROM FreeholdContract + WHERE titleHolderId IS NOT NULL + GROUP BY titleHolderId + """) + @ConstructorArgs({ + @Arg(column = "titleHolderId", javaType = UUID.class), + @Arg(column = "plotCount", javaType = int.class) + }) + @NotNull List selectPlotCountsByTitleHolder(); + } diff --git a/realty-paper/build.gradle.kts b/realty-paper/build.gradle.kts index 0c87f1c..57c73b4 100644 --- a/realty-paper/build.gradle.kts +++ b/realty-paper/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { exclude(group = "org.bukkit", module = "bukkit") exclude(group = "org.spigotmc", module = "spigot-api") } + compileOnly("net.democracycraft:treasury-api:2.0.0-SNAPSHOT") compileOnly("org.jetbrains:annotations:26.0.2-1") implementation("org.incendo:cloud-paper:2.0.0-beta.10") implementation("org.spongepowered:configurate-yaml:4.2.0") diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index 3012d35..b00b96c 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -54,6 +54,7 @@ import io.github.md5sha256.realty.database.RealtyBackendImpl; import io.github.md5sha256.realty.database.SqlSessionWrapper; import io.github.md5sha256.realty.database.maria.MariaDatabase; +import io.github.md5sha256.realty.listener.PropertyTaxListener; import io.github.md5sha256.realty.listener.SignInteractionListener; import io.github.md5sha256.realty.localisation.MessageContainer; import io.github.md5sha256.realty.localisation.MessageKeys; @@ -64,6 +65,7 @@ import io.github.md5sha256.realty.settings.RealtyTags; import io.github.md5sha256.realty.settings.RegionTagSettings; import io.github.md5sha256.realty.settings.Settings; +import io.github.md5sha256.realty.settings.TaxSettings; import io.github.md5sha256.realty.util.ComponentSerializer; import io.github.md5sha256.realty.util.DateFormatter; import io.github.md5sha256.realty.util.EssentialsNotificationService; @@ -73,6 +75,9 @@ import io.papermc.paper.util.Tick; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import io.github.md5sha256.realty.economy.EconomyProvider; +import io.github.md5sha256.realty.economy.TreasuryEconomyProvider; +import io.github.md5sha256.realty.economy.VaultEconomyProvider; import net.milkbowl.vault.economy.Economy; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -89,6 +94,7 @@ import org.incendo.cloud.paper.util.sender.PaperSimpleSenderMapper; import org.incendo.cloud.paper.util.sender.Source; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.spongepowered.configurate.ConfigurationNode; import org.spongepowered.configurate.yaml.NodeStyle; import org.spongepowered.configurate.yaml.YamlConfigurationLoader; @@ -122,6 +128,7 @@ public final class Realty extends JavaPlugin { private final AtomicReference settings = new AtomicReference<>(); private final AtomicReference regionFlagSettings = new AtomicReference<>(); private final AtomicReference realtyTags = new AtomicReference<>(); + private final AtomicReference taxSettings = new AtomicReference<>(); private final RegionProfileService regionProfileService = new RegionProfileService(getLogger()); private final SignCache signCache = new SignCache(); private ExecutorState executorState; @@ -166,6 +173,10 @@ public RealtyTags realtyTags() { return this.realtyTags.get(); } + public TaxSettings taxSettings() { + return this.taxSettings.get(); + } + @Override public void onLoad() { try { @@ -173,11 +184,13 @@ public void onLoad() { copyResourceTemplate("messages.yml", "defaults/default-messages.yml"); copyResourceTemplate("settings.yml", "defaults/default-settings.yml"); copyResourceTemplate("profiles.yml", "defaults/default-profiles.yml"); + copyResourceTemplate("taxes.yml", "defaults/default-taxes.yml"); reloadMessages(); this.databaseSettings = loadDatabaseSettings(); this.settings.set(loadSettings()); this.regionFlagSettings.set(loadRegionFlagSettings()); this.realtyTags.set(new RealtyTags(loadRegionTagSettings())); + this.taxSettings.set(loadTaxSettings()); registerTagPermissions(this.realtyTags.get()); configureRegionFlagService(this.regionFlagSettings.get()); @@ -223,9 +236,9 @@ public void onEnable() { return player.getName() != null ? player.getName() : uuid.toString(); }, dateTime -> DateFormatter.format(this.settings.get(), dateTime), () -> this.settings.get().offerPaymentDurationSeconds()); - var economyProvider = getServer().getServicesManager().getRegistration(Economy.class); + EconomyProvider economyProvider = resolveEconomyProvider(); if (economyProvider == null) { - getLogger().severe("Economy not found, plugin will now disable!"); + getLogger().severe("No economy found (neither Treasury nor Vault), plugin will now disable!"); getServer().getPluginManager().disablePlugin(this); return; } @@ -250,8 +263,16 @@ public void onEnable() { new SignInteractionListener(this.database, this.logic, this.regionProfileService, this.executorState, this.signCache, this.signTextApplicator, this.messageContainer), this); + var treasuryRegistration = getServer().getServicesManager() + .getRegistration(net.democracycraft.treasury.api.TreasuryApi.class); + if (treasuryRegistration != null) { + getServer().getPluginManager().registerEvents( + new PropertyTaxListener(this.database, treasuryRegistration.getProvider(), + this.taxSettings, getLogger()), this); + getLogger().info("Registered property tax listener (daily cycle)"); + } this.paperApi = new RealtyPaperApiImpl( - this.logic, economyProvider.getProvider(), this.executorState, this.database, + this.logic, economyProvider, this.executorState, this.database, this.regionProfileService, this.signTextApplicator, this.signCache); scheduleTasks(); registerCommands(this.paperApi, @@ -294,6 +315,24 @@ public void onDisable() { getLogger().info("Plugin disabled successfully"); } + private @Nullable EconomyProvider resolveEconomyProvider() { + if (getServer().getPluginManager().isPluginEnabled("Treasury")) { + var registration = getServer().getServicesManager() + .getRegistration(net.democracycraft.treasury.api.TreasuryApi.class); + if (registration != null) { + getLogger().info("Detected Treasury, using Treasury as the economy provider (full ledger support)"); + return new TreasuryEconomyProvider(registration.getProvider()); + } + getLogger().warning("Treasury plugin is loaded but TreasuryApi service is not registered; falling back to Vault"); + } + var registration = getServer().getServicesManager().getRegistration(Economy.class); + if (registration != null) { + getLogger().info("Using Vault as the economy provider"); + return new VaultEconomyProvider(registration.getProvider()); + } + return null; + } + private void scheduleTasks() { BukkitScheduler scheduler = getServer().getScheduler(); long intervalTicks = Tick.tick().fromDuration(Duration.ofMinutes(1)); @@ -400,6 +439,11 @@ private RegionTagSettings loadRegionTagSettings() throws IOException { return settingsRoot.get(RegionTagSettings.class); } + private TaxSettings loadTaxSettings() throws IOException { + ConfigurationNode settingsRoot = copyDefaultsYaml("taxes"); + return settingsRoot.get(TaxSettings.class); + } + private void unregisterTagPermissions(@NotNull RealtyTags realtyTags) { PluginManager pluginManager = getServer().getPluginManager(); for (ConfigRegionTag tag : realtyTags.values()) { @@ -493,6 +537,7 @@ private void performReload() throws IOException { registerTagPermissions(this.realtyTags.get()); configureRegionFlagService(this.regionFlagSettings.get()); this.profileApplicator.applyAll(this.settings.get().profileReapplyPerTick()); + this.taxSettings.set(loadTaxSettings()); reloadMessages(); warnOrphanedTags(); } diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java index dcce801..2c847d4 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java @@ -20,10 +20,9 @@ import io.github.md5sha256.realty.database.entity.RealtyRegionEntity; import io.github.md5sha256.realty.database.entity.RealtySignEntity; import io.github.md5sha256.realty.api.ExecutorState; -import net.milkbowl.vault.economy.Economy; -import net.milkbowl.vault.economy.EconomyResponse; +import io.github.md5sha256.realty.economy.EconomyProvider; +import io.github.md5sha256.realty.economy.PaymentResult; import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; import org.bukkit.World; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -39,7 +38,7 @@ public class RealtyPaperApiImpl implements RealtyPaperApi { private final RealtyBackend realtyApi; - private final Economy economy; + private final EconomyProvider economyProvider; private final ExecutorState executorState; private final Database database; private final RegionProfileService regionProfileService; @@ -47,14 +46,14 @@ public class RealtyPaperApiImpl implements RealtyPaperApi { private final SignCache signCache; public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, - @NotNull Economy economy, + @NotNull EconomyProvider economyProvider, @NotNull ExecutorState executorState, @NotNull Database database, @NotNull RegionProfileService regionProfileService, @NotNull SignTextApplicator signTextApplicator, @NotNull SignCache signCache) { this.realtyApi = realtyApi; - this.economy = economy; + this.economyProvider = economyProvider; this.executorState = executorState; this.database = database; this.regionProfileService = regionProfileService; @@ -105,23 +104,21 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, @NotNull RealtyBackend.BuyResult.Success reserved, @NotNull Map placeholders) { double price = reserved.price(); - OfflinePlayer buyer = Bukkit.getOfflinePlayer(buyerId); // Region is already reserved in DB. Process payment, rollback DB on failure. if (price > 0) { - double balance = economy.getBalance(buyer); + double balance = economyProvider.getBalance(buyerId); if (balance < price) { return rollbackBuyAsync(regionId, worldId, reserved.titleHolderId(), price) .thenApply(ignored -> new BuyResult.InsufficientFunds(price, balance)); } - EconomyResponse response = economy.withdrawPlayer(buyer, price); - if (!response.transactionSuccess()) { - return rollbackBuyAsync(regionId, worldId, reserved.titleHolderId(), price) - .thenApply(ignored -> new BuyResult.PaymentFailed(response.errorMessage)); - } UUID recipientId = reserved.titleHolderId() != null ? reserved.titleHolderId() : reserved.authorityId(); - OfflinePlayer recipient = Bukkit.getOfflinePlayer(recipientId); - economy.depositPlayer(recipient, price); + PaymentResult result = economyProvider.transfer( + buyerId, recipientId, price, "Plot Purchase: " + regionId); + if (result instanceof PaymentResult.Failure failure) { + return rollbackBuyAsync(regionId, worldId, reserved.titleHolderId(), price) + .thenApply(ignored -> new BuyResult.PaymentFailed(failure.errorMessage())); + } } ProtectedRegion protectedRegion = region.region(); protectedRegion.getOwners().clear(); @@ -179,21 +176,19 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, @NotNull RealtyBackend.RentResult.Success reserved, @NotNull Map placeholders) { double price = reserved.price(); - OfflinePlayer tenant = Bukkit.getOfflinePlayer(tenantId); // Region is already reserved in DB. Process payment, rollback DB on failure. if (price > 0) { - double balance = economy.getBalance(tenant); + double balance = economyProvider.getBalance(tenantId); if (balance < price) { return rollbackRentAsync(regionId, worldId) .thenApply(ignored -> new RentResult.InsufficientFunds(price, balance)); } - EconomyResponse response = economy.withdrawPlayer(tenant, price); - if (!response.transactionSuccess()) { + PaymentResult result = economyProvider.transfer( + tenantId, reserved.landlordId(), price, "Rental Payment: " + regionId); + if (result instanceof PaymentResult.Failure failure) { return rollbackRentAsync(regionId, worldId) - .thenApply(ignored -> new RentResult.PaymentFailed(response.errorMessage)); + .thenApply(ignored -> new RentResult.PaymentFailed(failure.errorMessage())); } - OfflinePlayer landlord = Bukkit.getOfflinePlayer(reserved.landlordId()); - economy.depositPlayer(landlord, price); } ProtectedRegion protectedRegion = region.region(); protectedRegion.getOwners().clear(); @@ -251,18 +246,12 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, double refund = success.refund(); // Tenant is already cleared in DB. Process refund, rollback DB on failure. if (refund > 0) { - OfflinePlayer landlord = Bukkit.getOfflinePlayer(success.landlordId()); - EconomyResponse withdrawResponse = economy.withdrawPlayer(landlord, refund); - if (!withdrawResponse.transactionSuccess()) { - return rollbackUnrentAsync(regionId, worldId, tenantId) - .thenApply(ignored -> new UnrentResult.RefundFailed(withdrawResponse.errorMessage)); - } - OfflinePlayer tenantPlayer = Bukkit.getOfflinePlayer(tenantId); - EconomyResponse depositResponse = economy.depositPlayer(tenantPlayer, refund); - if (!depositResponse.transactionSuccess()) { - economy.depositPlayer(landlord, refund); + PaymentResult result = economyProvider.transfer( + success.landlordId(), tenantId, refund, + "Early Lease Termination Refund: " + regionId); + if (result instanceof PaymentResult.Failure failure) { return rollbackUnrentAsync(regionId, worldId, tenantId) - .thenApply(ignored -> new UnrentResult.RefundFailed(depositResponse.errorMessage)); + .thenApply(ignored -> new UnrentResult.RefundFailed(failure.errorMessage())); } } ProtectedRegion protectedRegion = region.region(); @@ -318,21 +307,19 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, @NotNull RealtyBackend.RenewLeaseholdResult.Success reserved, @NotNull Map placeholders) { double price = reserved.price(); - OfflinePlayer tenant = Bukkit.getOfflinePlayer(tenantId); // Lease is already extended in DB. Process payment, rollback DB on failure. if (price > 0) { - double balance = economy.getBalance(tenant); + double balance = economyProvider.getBalance(tenantId); if (balance < price) { return rollbackExtendAsync(regionId, worldId, tenantId) .thenApply(ignored -> new ExtendResult.InsufficientFunds(price, balance)); } - EconomyResponse response = economy.withdrawPlayer(tenant, price); - if (!response.transactionSuccess()) { + PaymentResult result = economyProvider.transfer( + tenantId, reserved.landlordId(), price, "Lease Extension Payment: " + regionId); + if (result instanceof PaymentResult.Failure failure) { return rollbackExtendAsync(regionId, worldId, tenantId) - .thenApply(ignored -> new ExtendResult.PaymentFailed(response.errorMessage)); + .thenApply(ignored -> new ExtendResult.PaymentFailed(failure.errorMessage())); } - OfflinePlayer landlord = Bukkit.getOfflinePlayer(reserved.landlordId()); - economy.depositPlayer(landlord, price); } signTextApplicator.updateLoadedSigns(region.world(), regionId, RegionState.LEASED, placeholders); @@ -384,21 +371,20 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, @NotNull WorldGuardRegion region, @NotNull String regionId, @NotNull UUID worldId, @NotNull UUID bidderId, double amount, @NotNull RealtyBackend.PayBidResult.Success success) { - OfflinePlayer bidder = Bukkit.getOfflinePlayer(bidderId); - double balance = economy.getBalance(bidder); + double balance = economyProvider.getBalance(bidderId); if (balance < amount) { return rollbackPayBidAsync(regionId, worldId, bidderId, amount) .thenApply(ignored -> new PayBidResult.InsufficientFunds(balance)); } - EconomyResponse response = economy.withdrawPlayer(bidder, amount); - if (!response.transactionSuccess()) { - return rollbackPayBidAsync(regionId, worldId, bidderId, amount) - .thenApply(ignored -> new PayBidResult.PaymentFailed(response.errorMessage)); - } UUID recipientId = success.titleHolderId() != null ? success.titleHolderId() : success.authorityId(); - OfflinePlayer recipient = Bukkit.getOfflinePlayer(recipientId); - economy.depositPlayer(recipient, amount); + PaymentResult result = economyProvider.transfer( + bidderId, recipientId, amount, + "Auction Bid Payment (partial): " + regionId); + if (result instanceof PaymentResult.Failure failure) { + return rollbackPayBidAsync(regionId, worldId, bidderId, amount) + .thenApply(ignored -> new PayBidResult.PaymentFailed(failure.errorMessage())); + } return CompletableFuture.completedFuture( new PayBidResult.Success(amount, success.newTotal(), success.remaining(), regionId)); } @@ -407,21 +393,20 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, @NotNull WorldGuardRegion region, @NotNull String regionId, @NotNull UUID worldId, @NotNull UUID bidderId, double amount, @NotNull RealtyBackend.PayBidResult.FullyPaid fullyPaid) { - OfflinePlayer bidder = Bukkit.getOfflinePlayer(bidderId); - double balance = economy.getBalance(bidder); + double balance = economyProvider.getBalance(bidderId); if (balance < amount) { return rollbackPayBidAsync(regionId, worldId, bidderId, amount) .thenApply(ignored -> new PayBidResult.InsufficientFunds(balance)); } - EconomyResponse response = economy.withdrawPlayer(bidder, amount); - if (!response.transactionSuccess()) { - return rollbackPayBidAsync(regionId, worldId, bidderId, amount) - .thenApply(ignored -> new PayBidResult.PaymentFailed(response.errorMessage)); - } UUID recipientId = fullyPaid.titleHolderId() != null ? fullyPaid.titleHolderId() : fullyPaid.authorityId(); - OfflinePlayer recipient = Bukkit.getOfflinePlayer(recipientId); - economy.depositPlayer(recipient, amount); + PaymentResult result = economyProvider.transfer( + bidderId, recipientId, amount, + "Auction Bid Payment (final): " + regionId); + if (result instanceof PaymentResult.Failure failure) { + return rollbackPayBidAsync(regionId, worldId, bidderId, amount) + .thenApply(ignored -> new PayBidResult.PaymentFailed(failure.errorMessage())); + } ProtectedRegion protectedRegion = region.region(); protectedRegion.getOwners().clear(); protectedRegion.getOwners().addPlayer(bidderId); @@ -477,21 +462,20 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, @NotNull WorldGuardRegion region, @NotNull String regionId, @NotNull UUID worldId, @NotNull UUID offererId, double amount, @NotNull RealtyBackend.PayOfferResult.Success success) { - OfflinePlayer offerer = Bukkit.getOfflinePlayer(offererId); - double balance = economy.getBalance(offerer); + double balance = economyProvider.getBalance(offererId); if (balance < amount) { return rollbackPayOfferAsync(regionId, worldId, offererId, amount) .thenApply(ignored -> new PayOfferResult.InsufficientFunds(balance)); } - EconomyResponse response = economy.withdrawPlayer(offerer, amount); - if (!response.transactionSuccess()) { - return rollbackPayOfferAsync(regionId, worldId, offererId, amount) - .thenApply(ignored -> new PayOfferResult.PaymentFailed(response.errorMessage)); - } UUID recipientId = success.titleHolderId() != null ? success.titleHolderId() : success.authorityId(); - OfflinePlayer recipient = Bukkit.getOfflinePlayer(recipientId); - economy.depositPlayer(recipient, amount); + PaymentResult result = economyProvider.transfer( + offererId, recipientId, amount, + "Purchase Offer Payment (partial): " + regionId); + if (result instanceof PaymentResult.Failure failure) { + return rollbackPayOfferAsync(regionId, worldId, offererId, amount) + .thenApply(ignored -> new PayOfferResult.PaymentFailed(failure.errorMessage())); + } return CompletableFuture.completedFuture( new PayOfferResult.Success(amount, success.newTotal(), success.remaining(), regionId)); } @@ -500,21 +484,20 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, @NotNull WorldGuardRegion region, @NotNull String regionId, @NotNull UUID worldId, @NotNull UUID offererId, double amount, @NotNull RealtyBackend.PayOfferResult.FullyPaid fullyPaid) { - OfflinePlayer offerer = Bukkit.getOfflinePlayer(offererId); - double balance = economy.getBalance(offerer); + double balance = economyProvider.getBalance(offererId); if (balance < amount) { return rollbackPayOfferAsync(regionId, worldId, offererId, amount) .thenApply(ignored -> new PayOfferResult.InsufficientFunds(balance)); } - EconomyResponse response = economy.withdrawPlayer(offerer, amount); - if (!response.transactionSuccess()) { - return rollbackPayOfferAsync(regionId, worldId, offererId, amount) - .thenApply(ignored -> new PayOfferResult.PaymentFailed(response.errorMessage)); - } UUID recipientId = fullyPaid.titleHolderId() != null ? fullyPaid.titleHolderId() : fullyPaid.authorityId(); - OfflinePlayer recipient = Bukkit.getOfflinePlayer(recipientId); - economy.depositPlayer(recipient, amount); + PaymentResult result = economyProvider.transfer( + offererId, recipientId, amount, + "Purchase Offer Payment (final): " + regionId); + if (result instanceof PaymentResult.Failure failure) { + return rollbackPayOfferAsync(regionId, worldId, offererId, amount) + .thenApply(ignored -> new PayOfferResult.PaymentFailed(failure.errorMessage())); + } ProtectedRegion protectedRegion = region.region(); protectedRegion.getOwners().clear(); protectedRegion.getOwners().addPlayer(offererId); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/economy/EconomyProvider.java b/realty-paper/src/main/java/io/github/md5sha256/realty/economy/EconomyProvider.java new file mode 100644 index 0000000..fa332c3 --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/economy/EconomyProvider.java @@ -0,0 +1,42 @@ +package io.github.md5sha256.realty.economy; + +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +/** + * Abstraction over the server economy (Vault or Treasury). + * All methods are synchronous and may be called from any thread, + * though callers should avoid the main thread for Treasury which performs I/O. + */ +public interface EconomyProvider { + + /** + * Returns the current balance for the given player. + * Returns {@code 0.0} if the player has no account. + */ + double getBalance(@NotNull UUID playerId); + + /** + * Transfers {@code amount} from {@code fromId} to {@code toId}. + * The {@code ledgerMessage} is a human-readable description that appears + * in the player's transaction history (e.g. "Plot Purchase: my_plot"). + *

+ * The implementation is responsible for atomicity: if the deposit step + * fails, the withdrawal must be reversed before returning {@link PaymentResult.Failure}. + */ + @NotNull PaymentResult transfer(@NotNull UUID fromId, @NotNull UUID toId, + double amount, @NotNull String ledgerMessage); + + /** + * Formats an amount as a currency string using this economy's currency symbol/format. + */ + @NotNull String formatAmount(double amount); + + /** + * Returns {@code true} if this provider has full ledger support (Treasury). + * When {@code false} (Vault), ledger messages are silently discarded + * and tax collection is unavailable. + */ + boolean hasLedgerSupport(); +} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/economy/PaymentResult.java b/realty-paper/src/main/java/io/github/md5sha256/realty/economy/PaymentResult.java new file mode 100644 index 0000000..20aafbb --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/economy/PaymentResult.java @@ -0,0 +1,11 @@ +package io.github.md5sha256.realty.economy; + +/** + * Result of an economy transfer operation. + */ +public sealed interface PaymentResult { + + record Success() implements PaymentResult {} + + record Failure(String errorMessage) implements PaymentResult {} +} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/economy/TreasuryEconomyProvider.java b/realty-paper/src/main/java/io/github/md5sha256/realty/economy/TreasuryEconomyProvider.java new file mode 100644 index 0000000..970796b --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/economy/TreasuryEconomyProvider.java @@ -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.jetbrains.annotations.NotNull; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +/** + * Economy provider backed by Treasury. Provides full ledger support: + * each transfer is recorded with a human-readable message that appears + * in the player's Treasury transaction history. + *

+ * 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. + */ +public final class TreasuryEconomyProvider implements EconomyProvider { + + private static final String PLUGIN_SYSTEM = "realty"; + + private final TreasuryApi treasuryApi; + + public TreasuryEconomyProvider(@NotNull TreasuryApi treasuryApi) { + this.treasuryApi = treasuryApi; + } + + @Override + public double getBalance(@NotNull UUID playerId) { + if (!treasuryApi.hasAccountByOwnerUuid(playerId)) { + return 0.0; + } + BigDecimal balance = treasuryApi.getBalanceByOwnerUuid(playerId); + return balance != null ? balance.doubleValue() : 0.0; + } + + @Override + public @NotNull PaymentResult transfer(@NotNull UUID fromId, @NotNull UUID toId, + double amount, @NotNull String ledgerMessage) { + try { + Account payer = treasuryApi.resolveOrCreatePersonal(fromId); + Account recipient = resolveRecipientAccount(toId); + treasuryApi.transfer(new TransferRequest( + payer.getAccountId(), + recipient.getAccountId(), + BigDecimal.valueOf(amount), + ledgerMessage, + fromId, + null, + PLUGIN_SYSTEM, + null + )); + return new PaymentResult.Success(); + } catch (Exception e) { + return new PaymentResult.Failure(e.getMessage() != null ? e.getMessage() : "Treasury transfer failed"); + } + } + + @Override + public @NotNull String formatAmount(double amount) { + return treasuryApi.formatAmount(BigDecimal.valueOf(amount)); + } + + @Override + public boolean hasLedgerSupport() { + return true; + } + + /** + * 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. + */ + private @NotNull Account resolveRecipientAccount(@NotNull UUID ownerUuid) { + List 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) + .findFirst() + .or(() -> accounts.stream() + .filter(a -> a.getAccountType() == AccountType.BUSINESS) + .findFirst()) + .orElse(accounts.get(0)); + } + return treasuryApi.resolveOrCreatePersonal(ownerUuid); + } +} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/economy/VaultEconomyProvider.java b/realty-paper/src/main/java/io/github/md5sha256/realty/economy/VaultEconomyProvider.java new file mode 100644 index 0000000..e6ffba7 --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/economy/VaultEconomyProvider.java @@ -0,0 +1,56 @@ +package io.github.md5sha256.realty.economy; + +import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +/** + * Economy provider backed by Vault. Used when Treasury is not present. + * Ledger messages are discarded (Vault has no per-transaction metadata support). + * Tax collection is not available without Treasury. + */ +public final class VaultEconomyProvider implements EconomyProvider { + + private final Economy economy; + + public VaultEconomyProvider(@NotNull Economy economy) { + this.economy = economy; + } + + @Override + public double getBalance(@NotNull UUID playerId) { + return economy.getBalance(Bukkit.getOfflinePlayer(playerId)); + } + + @Override + public @NotNull PaymentResult transfer(@NotNull UUID fromId, @NotNull UUID toId, + double amount, @NotNull String ledgerMessage) { + OfflinePlayer payer = Bukkit.getOfflinePlayer(fromId); + EconomyResponse withdraw = economy.withdrawPlayer(payer, amount); + if (!withdraw.transactionSuccess()) { + return new PaymentResult.Failure(withdraw.errorMessage); + } + OfflinePlayer recipient = Bukkit.getOfflinePlayer(toId); + EconomyResponse deposit = economy.depositPlayer(recipient, amount); + if (!deposit.transactionSuccess()) { + // Rollback: return money to payer + economy.depositPlayer(payer, amount); + return new PaymentResult.Failure(deposit.errorMessage); + } + return new PaymentResult.Success(); + } + + @Override + public @NotNull String formatAmount(double amount) { + return economy.format(amount); + } + + @Override + public boolean hasLedgerSupport() { + return false; + } +} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/listener/PropertyTaxListener.java b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/PropertyTaxListener.java new file mode 100644 index 0000000..259c1de --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/PropertyTaxListener.java @@ -0,0 +1,163 @@ +package io.github.md5sha256.realty.listener; + +import io.github.md5sha256.realty.database.Database; +import io.github.md5sha256.realty.database.SqlSessionWrapper; +import io.github.md5sha256.realty.database.entity.PlotOwnerCount; +import io.github.md5sha256.realty.settings.TaxSettings; +import net.democracycraft.treasury.api.TreasuryApi; +import net.democracycraft.treasury.event.TaxCycleEvent; +import net.democracycraft.treasury.model.economy.Account; +import net.democracycraft.treasury.model.economy.AccountType; +import net.democracycraft.treasury.model.tax.TaxCollection; +import net.democracycraft.treasury.model.tax.TaxCycleType; +import net.democracycraft.treasury.model.tax.TaxResult; +import net.democracycraft.treasury.utils.Idempotency; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.jetbrains.annotations.NotNull; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +public final class PropertyTaxListener implements Listener { + + private static final UUID SYSTEM_UUID = new UUID(0L, 0L); + private static final String PLUGIN_SYSTEM = "realty"; + private static final String TAX_TYPE = "property_tax"; + + private final Database database; + private final TreasuryApi treasuryApi; + private final AtomicReference taxSettings; + private final Logger logger; + + public PropertyTaxListener( + @NotNull Database database, + @NotNull TreasuryApi treasuryApi, + @NotNull AtomicReference taxSettings, + @NotNull Logger logger + ) { + this.database = database; + this.treasuryApi = treasuryApi; + this.taxSettings = taxSettings; + this.logger = logger; + } + + @EventHandler + public void onTaxCycle(@NotNull TaxCycleEvent event) { + if (event.getCycleType() != TaxCycleType.DAILY) { + return; + } + TaxSettings settings = taxSettings.get(); + if (!settings.enabled()) { + return; + } + + List plotCounts; + try (SqlSessionWrapper session = database.openSession(true)) { + plotCounts = session.freeholdContractMapper().selectPlotCountsByTitleHolder(); + } catch (Exception e) { + logger.severe("Failed to load plot counts for property tax collection: " + e.getMessage()); + return; + } + + Set exempt = new HashSet<>(settings.exemptUuids()); + Instant periodStart = event.getPeriodStart(); + + // Resolve the configured destination account once for the whole batch. + Integer destinationAccountId = resolveDestinationAccountId(settings.governmentAccount()); + + List collections = new ArrayList<>(); + for (PlotOwnerCount entry : plotCounts) { + UUID owner = entry.titleHolderId(); + int plots = entry.plotCount(); + + if (plots <= 3) { + continue; + } + if (exempt.contains(owner)) { + continue; + } + + int accountId = resolvePersonalAccountId(owner); + if (accountId == -1) { + logger.warning("No Treasury account found for plot owner " + owner + ", skipping property tax"); + continue; + } + + BigDecimal taxAmount = computePropertyTax(plots); + byte[] dedupKey = Idempotency.sha256("realty:property_tax:" + owner + ":" + periodStart.toEpochMilli()); + + TaxCollection collection = destinationAccountId != null + ? TaxCollection.toAccount(accountId, destinationAccountId, taxAmount, TAX_TYPE, + "Daily property tax (" + plots + " plots)", SYSTEM_UUID, PLUGIN_SYSTEM, dedupKey) + : TaxCollection.toDefaultAccount(accountId, taxAmount, TAX_TYPE, + "Daily property tax (" + plots + " plots)", SYSTEM_UUID, PLUGIN_SYSTEM, dedupKey); + + collections.add(collection); + } + + if (collections.isEmpty()) { + return; + } + + List results = event.getTaxApi().collectBatch(collections); + + long collected = 0; + long skipped = 0; + long failed = 0; + for (TaxResult result : results) { + if (result.isSuccess()) { + collected++; + } else if (result.wasSkipped()) { + skipped++; + } else { + failed++; + if (result instanceof TaxResult.Failed f) { + logger.warning("Property tax collection failure: " + f.errorMessage()); + } + } + } + logger.info("Daily property tax cycle: " + collected + " collected, " + skipped + " skipped, " + failed + " failed"); + } + + /** + * Resolves the configured government account name to an account ID. + * Returns {@code null} if the account is not found, causing the collection + * to fall back to Treasury's default tax account. + */ + private @org.jetbrains.annotations.Nullable Integer resolveDestinationAccountId(@NotNull String accountName) { + Account account = treasuryApi.getGovernmentAccountByName(accountName); + if (account == null) { + logger.warning("Configured property tax destination '" + accountName + + "' not found in Treasury — falling back to Treasury's default tax account"); + return null; + } + return account.getAccountId(); + } + + // y = 2.5 * x^2 - 6 * x, rounded to 2 decimal places + private static @NotNull BigDecimal computePropertyTax(int plots) { + double raw = 2.5 * ((double) plots * plots) - 6.0 * plots; + return BigDecimal.valueOf(raw).setScale(2, RoundingMode.HALF_UP); + } + + private int resolvePersonalAccountId(@NotNull UUID owner) { + List accounts = treasuryApi.getAccountsByOwner(owner); + if (accounts.isEmpty()) { + return -1; + } + return accounts.stream() + .filter(a -> a.getAccountType() == AccountType.PERSONAL) + .findFirst() + .orElse(accounts.get(0)) + .getAccountId(); + } +} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxSettings.java b/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxSettings.java new file mode 100644 index 0000000..62fb808 --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxSettings.java @@ -0,0 +1,25 @@ +package io.github.md5sha256.realty.settings; + +import org.jetbrains.annotations.NotNull; +import org.spongepowered.configurate.objectmapping.ConfigSerializable; +import org.spongepowered.configurate.objectmapping.meta.Setting; + +import java.util.List; +import java.util.UUID; + +@ConfigSerializable +public record TaxSettings( + @Setting("enabled") boolean enabled, + @Setting("government-account") @NotNull String governmentAccount, + @Setting("exempt-uuids") @NotNull List exemptUuids +) { + + public TaxSettings { + if (governmentAccount == null || governmentAccount.isBlank()) { + governmentAccount = "DCGovernment"; + } + if (exemptUuids == null) { + exemptUuids = List.of(); + } + } +} diff --git a/realty-paper/src/main/resources/paper-plugin.yml b/realty-paper/src/main/resources/paper-plugin.yml index d44efb5..14f2c5a 100644 --- a/realty-paper/src/main/resources/paper-plugin.yml +++ b/realty-paper/src/main/resources/paper-plugin.yml @@ -17,6 +17,10 @@ dependencies: load: BEFORE join-classpath: true required: false + Treasury: + load: BEFORE + join-classpath: true + required: false permissions: realty.command.agent.invite: description: Allows using /realty agent invite diff --git a/realty-paper/src/main/resources/taxes.yml b/realty-paper/src/main/resources/taxes.yml new file mode 100644 index 0000000..f89ed27 --- /dev/null +++ b/realty-paper/src/main/resources/taxes.yml @@ -0,0 +1,14 @@ +# Enable or disable daily property tax collection. +# Requires the Treasury plugin to be installed and its DAILY cycle to be enabled. +# Tax formula: tax = 2.5 * (plots^2) - 6 * plots +# Players with 3 or fewer plots are exempt from taxation. +enabled: true + +# Name of the Treasury GOVERNMENT account that receives property tax proceeds. +# Must match the display name of an existing GOVERNMENT account in Treasury. +# Defaults to DCGovernment if left blank or omitted. +government-account: "DCGovernment" + +# UUIDs exempt from property tax collection. +# Useful for authority, government, or server-owned accounts. +exempt-uuids: [] diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java index ca3e426..b04308b 100644 --- a/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java @@ -8,10 +8,9 @@ import com.sk89q.worldguard.protection.regions.ProtectedRegion; import com.sk89q.worldguard.protection.regions.RegionContainer; import io.github.md5sha256.realty.database.Database; -import net.milkbowl.vault.economy.Economy; -import net.milkbowl.vault.economy.EconomyResponse; +import io.github.md5sha256.realty.economy.EconomyProvider; +import io.github.md5sha256.realty.economy.PaymentResult; import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; import org.bukkit.World; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; @@ -46,7 +45,7 @@ class RealtyPaperApiImplTest { @Mock private RealtyBackend realtyApi; @Mock - private Economy economy; + private EconomyProvider economyProvider; @Mock private Database database; @Mock @@ -55,8 +54,6 @@ class RealtyPaperApiImplTest { private SignTextApplicator signTextApplicator; @Mock private World world; - @Mock - private OfflinePlayer offlinePlayer; private SignCache signCache; private RealtyPaperApiImpl api; @@ -79,7 +76,7 @@ class RealtyPaperApiImplTest { void setUp() { signCache = new SignCache(); ExecutorState executorState = new ExecutorState(Runnable::run, sameThreadExecutorService()); - api = new RealtyPaperApiImpl(realtyApi, economy, executorState, database, + api = new RealtyPaperApiImpl(realtyApi, economyProvider, executorState, database, regionProfileService, signTextApplicator, signCache); lenient().when(world.getUID()).thenReturn(WORLD_ID); @@ -89,8 +86,6 @@ void setUp() { wgRegion = new WorldGuardRegion(protectedRegion, world); bukkitMock = mockStatic(Bukkit.class); - bukkitMock.when(() -> Bukkit.getOfflinePlayer(any(UUID.class))) - .thenReturn(offlinePlayer); // Mock WorldGuard static chain: getInstance() -> platform -> regionContainer -> get() -> null // Returning null for RegionManager makes updateChildLandlords return early @@ -151,14 +146,6 @@ public boolean awaitTermination(long timeout, TimeUnit unit) { }; } - private EconomyResponse successResponse(double amount) { - return new EconomyResponse(amount, amount, EconomyResponse.ResponseType.SUCCESS, null); - } - - private EconomyResponse failureResponse(String message) { - return new EconomyResponse(0, 0, EconomyResponse.ResponseType.FAILURE, message); - } - // ═══════════════════════════════════════════════════ // buy() // ═══════════════════════════════════════════════════ @@ -218,7 +205,7 @@ void insufficientFunds() { .thenReturn(new RealtyBackend.BuyResult.Success(1000.0, AUTHORITY_ID, TITLE_HOLDER_ID)); when(realtyApi.getRegionPlaceholders(REGION_ID, WORLD_ID)) .thenReturn(Map.of()); - when(economy.getBalance(any(OfflinePlayer.class))).thenReturn(500.0); + when(economyProvider.getBalance(BUYER_ID)).thenReturn(500.0); RealtyPaperApi.BuyResult result = api.buy(wgRegion, BUYER_ID).join(); @@ -237,9 +224,9 @@ void paymentFailed() { .thenReturn(new RealtyBackend.BuyResult.Success(1000.0, AUTHORITY_ID, TITLE_HOLDER_ID)); when(realtyApi.getRegionPlaceholders(REGION_ID, WORLD_ID)) .thenReturn(Map.of()); - when(economy.getBalance(any(OfflinePlayer.class))).thenReturn(2000.0); - when(economy.withdrawPlayer(any(OfflinePlayer.class), eq(1000.0))) - .thenReturn(failureResponse("Bank error")); + when(economyProvider.getBalance(BUYER_ID)).thenReturn(2000.0); + when(economyProvider.transfer(eq(BUYER_ID), any(UUID.class), eq(1000.0), any())) + .thenReturn(new PaymentResult.Failure("Bank error")); RealtyPaperApi.BuyResult result = api.buy(wgRegion, BUYER_ID).join(); @@ -254,11 +241,9 @@ void success() { .thenReturn(new RealtyBackend.BuyResult.Success(1000.0, AUTHORITY_ID, TITLE_HOLDER_ID)); when(realtyApi.getRegionPlaceholders(REGION_ID, WORLD_ID)) .thenReturn(Map.of("price", "1000")); - when(economy.getBalance(any(OfflinePlayer.class))).thenReturn(2000.0); - when(economy.withdrawPlayer(any(OfflinePlayer.class), eq(1000.0))) - .thenReturn(successResponse(1000.0)); - when(economy.depositPlayer(any(OfflinePlayer.class), eq(1000.0))) - .thenReturn(successResponse(1000.0)); + when(economyProvider.getBalance(BUYER_ID)).thenReturn(2000.0); + when(economyProvider.transfer(eq(BUYER_ID), any(UUID.class), eq(1000.0), any())) + .thenReturn(new PaymentResult.Success()); RealtyPaperApi.BuyResult result = api.buy(wgRegion, BUYER_ID).join(); @@ -327,7 +312,7 @@ void insufficientFunds() { .thenReturn(new RealtyBackend.RentResult.Success(500.0, 3600, LANDLORD_ID)); when(realtyApi.getRegionPlaceholders(REGION_ID, WORLD_ID)) .thenReturn(Map.of()); - when(economy.getBalance(any(OfflinePlayer.class))).thenReturn(100.0); + when(economyProvider.getBalance(TENANT_ID)).thenReturn(100.0); RealtyPaperApi.RentResult result = api.rent(wgRegion, TENANT_ID).join(); @@ -342,11 +327,9 @@ void success() { .thenReturn(new RealtyBackend.RentResult.Success(500.0, 3600, LANDLORD_ID)); when(realtyApi.getRegionPlaceholders(REGION_ID, WORLD_ID)) .thenReturn(Map.of()); - when(economy.getBalance(any(OfflinePlayer.class))).thenReturn(1000.0); - when(economy.withdrawPlayer(any(OfflinePlayer.class), eq(500.0))) - .thenReturn(successResponse(500.0)); - when(economy.depositPlayer(any(OfflinePlayer.class), eq(500.0))) - .thenReturn(successResponse(500.0)); + when(economyProvider.getBalance(TENANT_ID)).thenReturn(1000.0); + when(economyProvider.transfer(eq(TENANT_ID), eq(LANDLORD_ID), eq(500.0), any())) + .thenReturn(new PaymentResult.Success()); RealtyPaperApi.RentResult result = api.rent(wgRegion, TENANT_ID).join(); @@ -366,8 +349,7 @@ void zeroPriceSkipsPayment() { RealtyPaperApi.RentResult result = api.rent(wgRegion, TENANT_ID).join(); Assertions.assertInstanceOf(RealtyPaperApi.RentResult.Success.class, result); - verify(economy, never()).withdrawPlayer(any(OfflinePlayer.class), anyDouble()); - verify(economy, never()).depositPlayer(any(OfflinePlayer.class), anyDouble()); + verify(economyProvider, never()).transfer(any(), any(), anyDouble(), any()); } @Test @@ -408,10 +390,8 @@ void success() { .thenReturn(new RealtyBackend.UnrentResult.Success(100.0, TENANT_ID, LANDLORD_ID)); when(realtyApi.getRegionPlaceholders(REGION_ID, WORLD_ID)) .thenReturn(Map.of()); - when(economy.withdrawPlayer(any(OfflinePlayer.class), anyDouble())) - .thenReturn(successResponse(100.0)); - when(economy.depositPlayer(any(OfflinePlayer.class), anyDouble())) - .thenReturn(successResponse(100.0)); + when(economyProvider.transfer(eq(LANDLORD_ID), eq(TENANT_ID), eq(100.0), any())) + .thenReturn(new PaymentResult.Success()); protectedRegion.getOwners().addPlayer(TENANT_ID); @@ -432,8 +412,8 @@ void refundFailedOnLandlordWithdraw() { .thenReturn(Map.of()); when(realtyApi.rentRegion(REGION_ID, WORLD_ID, TENANT_ID)) .thenReturn(new RealtyBackend.RentResult.Success(100.0, 3600, LANDLORD_ID)); - when(economy.withdrawPlayer(any(OfflinePlayer.class), anyDouble())) - .thenReturn(failureResponse("Insufficient funds")); + when(economyProvider.transfer(eq(LANDLORD_ID), eq(TENANT_ID), eq(100.0), any())) + .thenReturn(new PaymentResult.Failure("Insufficient funds")); RealtyPaperApi.UnrentResult result = api.unrent(wgRegion, TENANT_ID).join(); @@ -479,7 +459,7 @@ void insufficientFunds() { .thenReturn(new RealtyBackend.RenewLeaseholdResult.Success(200.0, LANDLORD_ID)); when(realtyApi.getRegionPlaceholders(REGION_ID, WORLD_ID)) .thenReturn(Map.of()); - when(economy.getBalance(any(OfflinePlayer.class))).thenReturn(50.0); + when(economyProvider.getBalance(TENANT_ID)).thenReturn(50.0); RealtyPaperApi.ExtendResult result = api.extend(wgRegion, TENANT_ID).join(); @@ -494,11 +474,9 @@ void success() { .thenReturn(new RealtyBackend.RenewLeaseholdResult.Success(200.0, LANDLORD_ID)); when(realtyApi.getRegionPlaceholders(REGION_ID, WORLD_ID)) .thenReturn(Map.of()); - when(economy.getBalance(any(OfflinePlayer.class))).thenReturn(500.0); - when(economy.withdrawPlayer(any(OfflinePlayer.class), eq(200.0))) - .thenReturn(successResponse(200.0)); - when(economy.depositPlayer(any(OfflinePlayer.class), eq(200.0))) - .thenReturn(successResponse(200.0)); + when(economyProvider.getBalance(TENANT_ID)).thenReturn(500.0); + when(economyProvider.transfer(eq(TENANT_ID), eq(LANDLORD_ID), eq(200.0), any())) + .thenReturn(new PaymentResult.Success()); RealtyPaperApi.ExtendResult result = api.extend(wgRegion, TENANT_ID).join();