diff --git a/README.md b/README.md index eec0710..46874d5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This plugin also has some optional dependencies for expanded features Includes pay and balance commands for businesses as well as adding an option for paying businesses * `/firm pay ` * `/firm balance ` +* `/firm transactions [page]` * `/firm list` * `/pay ` diff --git a/src/main/kotlin/me/zodd/postmanpat/addons/PlayerBusinessAddon.kt b/src/main/kotlin/me/zodd/postmanpat/addons/PlayerBusinessAddon.kt index 00456af..e653d17 100644 --- a/src/main/kotlin/me/zodd/postmanpat/addons/PlayerBusinessAddon.kt +++ b/src/main/kotlin/me/zodd/postmanpat/addons/PlayerBusinessAddon.kt @@ -5,6 +5,7 @@ import com.olziedev.playerbusinesses.api.PlayerBusinessesAPI import com.olziedev.playerbusinesses.api.business.BStaff import com.olziedev.playerbusinesses.api.business.Business import com.olziedev.playerbusinesses.api.business.BusinessPermission +import com.olziedev.playerbusinesses.api.business.transaction.BEntry import github.scarsz.discordsrv.dependencies.jda.api.EmbedBuilder import github.scarsz.discordsrv.dependencies.jda.api.events.interaction.SlashCommandEvent import me.zodd.postmanpat.PostmanPat @@ -17,6 +18,13 @@ import me.zodd.postmanpat.econ.PostmanEconManager import me.zodd.postmanpat.econ.entity.BusinessEntity import me.zodd.postmanpat.econ.entity.UserEntity import java.awt.Color +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.UUID +import kotlin.math.max internal class PlayerBusinessAddon : CommandUtils { @@ -25,6 +33,9 @@ internal class PlayerBusinessAddon : CommandUtils { } private val econConf get() = PostmanPat.plugin.configManager.conf.moduleConfig.econ + private val timestampFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss") + .withZone(ZoneId.systemDefault()) + private val transactionPageSize = 10 internal fun listOwnedBusinesses(command : CommandEvent) { val embedBuilder = EmbedBuilder() @@ -41,11 +52,7 @@ internal class PlayerBusinessAddon : CommandUtils { } internal fun firmBal(command : CommandEvent) { - val businessName = command.event["business"]?.asString - val business: Business = pba.getBusinessByName(businessName?.lowercase()) ?: run { - command.event.replyEphemeral("Business by name [$businessName] was not found!").queue() - return - } + val business = command.businessOrReply() ?: return command.event.replyEphemeralEmbed( embedMessage( "Balance for ${business.name}", @@ -56,17 +63,12 @@ internal class PlayerBusinessAddon : CommandUtils { internal fun firmPay(command : CommandEvent) { - val businessName = command.event["business"]?.asString - val targetUser = command.event.userOrPlayerArg() ?: run { command.event.replyEphemeral("Unable to find user! Ensure name is spelled correctly or try @tagging them").queue() return } - val business: Business = pba.getBusinessByName(businessName?.lowercase()) ?: run { - command.event.replyEphemeral("Business by name [$businessName] was not found!").queue() - return - } + val business = command.businessOrReply() ?: return command.sender.hasFirmPermission(command.event, business) ?: return @@ -75,6 +77,154 @@ internal class PlayerBusinessAddon : CommandUtils { PostmanEconManager(businessSender, command.event).transferFunds(receiver) } + internal fun firmTransactions(command: CommandEvent) { + val business = command.businessOrReply() ?: return + command.sender.hasFirmPermission(command.event, business) ?: return + + val transactions = business.transactions.getTransactions() + .sortedByDescending { it.time } + + if (transactions.isEmpty()) { + command.event.replyEphemeral("Business ${business.name} has no transactions to export.").queue() + return + } + + val requestedPage = command.event["page"]?.asLong?.toInt() + val export = buildTransactionExport(business, command.sender.name, transactions, requestedPage) + ?: run { + command.event.replyEphemeral("Page $requestedPage is out of range for ${business.name}.").queue() + return + } + val fileName = buildTransactionFileName(business.name, requestedPage) + + command.event.reply("Attached is the transaction export for ${business.name}.") + .addFile(export.toByteArray(StandardCharsets.UTF_8), fileName) + .setEphemeral(true) + .queue() + } + + private fun buildTransactionExport( + business: Business, + requestedBy: String, + transactions: List, + requestedPage: Int? + ): String? { + val pages = transactions.chunked(transactionPageSize) + val totalPages = max(pages.size, 1) + + val pageExports = if (requestedPage == null) { + pages.mapIndexed { index, pageTransactions -> + buildTransactionPage(business.name, index + 1, totalPages, pageTransactions) + } + } else { + val pageIndex = requestedPage - 1 + val pageTransactions = pages.getOrNull(pageIndex) ?: return null + listOf(buildTransactionPage(business.name, requestedPage, totalPages, pageTransactions)) + } + + return buildString { + appendLine("Business Transactions Export") + appendLine("Business: ${business.name}") + appendLine("Requested by: $requestedBy") + appendLine("Exported at: ${timestampFormat.format(Instant.now())}") + appendLine("Total entries: ${transactions.size}") + appendLine("Page size: $transactionPageSize") + appendLine() + append(pageExports.joinToString("\n\n")) + } + } + + private fun buildTransactionPage( + businessName: String, + pageNumber: Int, + totalPages: Int, + transactions: List + ): String { + return buildString { + appendLine("BUSINESS TRANSACTIONS >> $businessName (Page $pageNumber/$totalPages)") + transactions.forEach { appendLine(formatTransactionEntry(it)) } + }.trimEnd() + } + + private fun formatTransactionEntry(entry: BEntry): String { + val actor = resolvePlayerName(entry.uuid) + val amount = "${econConf.currencySymbol}${econConf.decimalFormat().format(entry.amount)}" + val action = entry.actionText(actor, amount) + return "${timestampFormat.format(Instant.ofEpochMilli(entry.time))} $action" + } + + private fun resolvePlayerName(uuid: UUID): String { + return PostmanPat.plugin.ess.getUser(uuid)?.name + ?: PostmanPat.plugin.server.getOfflinePlayer(uuid).name + ?: uuid.toString() + } + + private fun reflectTarget(entry: BEntry): String? { + val target = entry.invokeAccessor("getTargetUUID", "getTarget") + val targetUuid = when (target) { + is UUID -> target + is String -> runCatching { UUID.fromString(target) }.getOrNull() + else -> null + } + + return targetUuid?.let(::resolvePlayerName) + } + + private fun reflectReason(entry: BEntry): String? { + return entry.invokeAccessor("getReason") as? String + } + + private fun BEntry.actionText(actor: String, amount: String): String { + val normalizedType = type.lowercase(Locale.ENGLISH) + val target = reflectTarget(this) + val reason = reflectReason(this)?.normalizeWhitespace() + + val baseText = when (normalizedType) { + "deposit", "deposited" -> "$actor deposited $amount" + "withdraw", "withdrawn", "withdrew" -> "$actor withdrew $amount" + "paid", "pay" -> target?.let { "$actor paid $amount to $it" } ?: "$actor paid $amount" + "tax", "taxed" -> "$actor taxed $amount" + else -> "$actor $normalizedType $amount" + } + + return reason?.takeIf { it.isNotBlank() }?.let { "$baseText [$it]" } ?: baseText + } + + private fun BEntry.invokeAccessor(vararg methodNames: String): Any? { + return methodNames.firstNotNullOfOrNull { method -> + runCatching { + javaClass.methods.firstOrNull { + it.name == method && it.parameterCount == 0 + }?.invoke(this) + }.getOrNull() + } + } + + private fun String.normalizeWhitespace(): String { + return replace("\\s+".toRegex(), " ").trim() + } + + private fun String.sanitizedFileName(): String { + return lowercase(Locale.ENGLISH) + .replace("[^a-z0-9._-]+".toRegex(), "-") + .trim('-') + .ifBlank { "business" } + } + + private fun buildTransactionFileName(businessName: String, requestedPage: Int?): String { + val baseName = businessName.sanitizedFileName() + return requestedPage?.let { "$baseName-transactions-page-$it.txt" } + ?: "$baseName-transactions-export.txt" + } + + private fun CommandEvent.businessOrReply(): Business? { + val businessName = event["business"]?.asString + return pba.getBusinessByName(businessName?.lowercase()) ?: run { + event.replyEphemeral("Business by name [$businessName] was not found!").queue() + null + } + } + private fun User.hasFirmPermission(event: SlashCommandEvent, business: Business): BStaff? { return business.staff?.firstOrNull { val permCheck = it.role.permission @@ -91,4 +241,4 @@ internal class PlayerBusinessAddon : CommandUtils { internal fun businessByName(name: String): Business? { return pba.getBusinessByName(name.lowercase()) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/me/zodd/postmanpat/econ/EconConfig.kt b/src/main/kotlin/me/zodd/postmanpat/econ/EconConfig.kt index 26025ee..cfa806e 100644 --- a/src/main/kotlin/me/zodd/postmanpat/econ/EconConfig.kt +++ b/src/main/kotlin/me/zodd/postmanpat/econ/EconConfig.kt @@ -22,6 +22,8 @@ internal data class EconConfig( val firmPayCommand: String = "pay", @field:Comment("Sub command for displaying firm balance") val firmBalCommand : String = "balance", + @field:Comment("Sub command for exporting firm transactions") + val firmTransactionsCommand: String = "transactions", @field:Comment("Sub command for displaying owned businesses") val firmListBusinesses : String = "list", @field:Comment("Whether the Economy module is enabled") @@ -30,4 +32,4 @@ internal data class EconConfig( internal fun decimalFormat(): DecimalFormat { return DecimalFormat(decimalFormat) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/me/zodd/postmanpat/econ/EconSlashCommands.kt b/src/main/kotlin/me/zodd/postmanpat/econ/EconSlashCommands.kt index cc582a2..8a833f3 100644 --- a/src/main/kotlin/me/zodd/postmanpat/econ/EconSlashCommands.kt +++ b/src/main/kotlin/me/zodd/postmanpat/econ/EconSlashCommands.kt @@ -29,6 +29,10 @@ internal class EconSlashCommands : PostmanCommandProvider { plugin.configManager.conf.moduleConfig.econ.firmListBusinesses, "Command to check a business' balance" ), + ECON_FIRM_TRANSACTIONS( + plugin.configManager.conf.moduleConfig.econ.firmTransactionsCommand, + "Export a business transaction history" + ), ECON_FIRM_BALANCE( plugin.configManager.conf.moduleConfig.econ.firmBalCommand, "Lists businesses you have financial access to" @@ -48,6 +52,10 @@ internal class EconSlashCommands : PostmanCommandProvider { plugin.pba?.let { it::listOwnedBusinesses } ?: emptyCommand() } + ECON_FIRM_TRANSACTIONS -> { + plugin.pba?.let { it::firmTransactions } ?: emptyCommand() + } + ECON_FIRM_BALANCE -> { plugin.pba?.let { it::firmBal @@ -127,6 +135,13 @@ internal class EconSlashCommands : PostmanCommandProvider { .addOption( OptionType.STRING, "business", "business to check balance of", true ), + EconCommands.ECON_FIRM_TRANSACTIONS.subcommandData() + .addOption( + OptionType.STRING, "business", "business to export transactions for", true + ) + .addOption( + OptionType.INTEGER, "page", "page to export from the in game transaction list", false + ), EconCommands.ECON_FIRM_LIST.subcommandData() ) )