import { inject, injectable } from "tsyringe"; import { HandbookHelper } from "@spt-aki/helpers/HandbookHelper"; import { InventoryHelper } from "@spt-aki/helpers/InventoryHelper"; import { ItemHelper } from "@spt-aki/helpers/ItemHelper"; import { PaymentHelper } from "@spt-aki/helpers/PaymentHelper"; import { TraderHelper } from "@spt-aki/helpers/TraderHelper"; import { IPmcData } from "@spt-aki/models/eft/common/IPmcData"; import { Item } from "@spt-aki/models/eft/common/tables/IItem"; import { IAddItemsDirectRequest } from "@spt-aki/models/eft/inventory/IAddItemsDirectRequest"; import { IItemEventRouterResponse } from "@spt-aki/models/eft/itemEvent/IItemEventRouterResponse"; import { IProcessBuyTradeRequestData } from "@spt-aki/models/eft/trade/IProcessBuyTradeRequestData"; import { IProcessSellTradeRequestData } from "@spt-aki/models/eft/trade/IProcessSellTradeRequestData"; import { BackendErrorCodes } from "@spt-aki/models/enums/BackendErrorCodes"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; import { DatabaseServer } from "@spt-aki/servers/DatabaseServer"; import { LocalisationService } from "@spt-aki/services/LocalisationService"; import { HashUtil } from "@spt-aki/utils/HashUtil"; import { HttpResponseUtil } from "@spt-aki/utils/HttpResponseUtil"; @injectable() export class PaymentService { constructor( @inject("WinstonLogger") protected logger: ILogger, @inject("HashUtil") protected hashUtil: HashUtil, @inject("HttpResponseUtil") protected httpResponse: HttpResponseUtil, @inject("DatabaseServer") protected databaseServer: DatabaseServer, @inject("HandbookHelper") protected handbookHelper: HandbookHelper, @inject("TraderHelper") protected traderHelper: TraderHelper, @inject("ItemHelper") protected itemHelper: ItemHelper, @inject("InventoryHelper") protected inventoryHelper: InventoryHelper, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("PaymentHelper") protected paymentHelper: PaymentHelper, ) {} /** * Take money and insert items into return to server request * @param pmcData Pmc profile * @param request Buy item request * @param sessionID Session id * @param output Client response */ public payMoney( pmcData: IPmcData, request: IProcessBuyTradeRequestData, sessionID: string, output: IItemEventRouterResponse, ): void { // May need to convert to trader currency const trader = this.traderHelper.getTrader(request.tid, sessionID); // Track the amounts of each type of currency involved in the trade. const currencyAmounts: { [key: string]: number } = {}; // Delete barter items and track currencies for (const index in request.scheme_items) { // Find the corresponding item in the player's inventory. const item = pmcData.Inventory.items.find((i) => i._id === request.scheme_items[index].id); if (item !== undefined) { if (!this.paymentHelper.isMoneyTpl(item._tpl)) { // If the item is not money, remove it from the inventory. this.inventoryHelper.removeItemByCount( pmcData, item._id, request.scheme_items[index].count, sessionID, output, ); request.scheme_items[index].count = 0; } else { // If the item is money, add its count to the currencyAmounts object. currencyAmounts[item._tpl] = (currencyAmounts[item._tpl] || 0) + request.scheme_items[index].count; } } else { // Used by `SptInsure` // Handle differently, `id` is the money type tpl const currencyTpl = request.scheme_items[index].id; currencyAmounts[currencyTpl] = (currencyAmounts[currencyTpl] || 0) + request.scheme_items[index].count; } } // Track the total amount of all currencies. let totalCurrencyAmount = 0; // Loop through each type of currency involved in the trade. for (const currencyTpl in currencyAmounts) { const currencyAmount = currencyAmounts[currencyTpl]; totalCurrencyAmount += currencyAmount; if (currencyAmount > 0) { // Find money stacks in inventory and remove amount needed + update output object to inform client of changes this.addPaymentToOutput(pmcData, currencyTpl, currencyAmount, sessionID, output); // If there are warnings, exit early. if (output.warnings.length > 0) { return; } // Convert the amount to the trader's currency and update the sales sum. const costOfPurchaseInCurrency = this.handbookHelper.fromRUB( this.handbookHelper.inRUB(currencyAmount, currencyTpl), this.paymentHelper.getCurrency(trader.currency), ); pmcData.TradersInfo[request.tid].salesSum += costOfPurchaseInCurrency; } } // If no currency-based payment is involved, handle it separately if (totalCurrencyAmount === 0) { this.logger.debug(this.localisationService.getText("payment-zero_price_no_payment")); // Convert the handbook price to the trader's currency and update the sales sum. const costOfPurchaseInCurrency = this.handbookHelper.fromRUB( this.getTraderItemHandbookPriceRouble(request.item_id, request.tid), this.paymentHelper.getCurrency(trader.currency), ); pmcData.TradersInfo[request.tid].salesSum += costOfPurchaseInCurrency; } this.traderHelper.lvlUp(request.tid, pmcData); this.logger.debug("Item(s) taken. Status OK."); } /** * Get the item price of a specific traders assort * @param traderAssortId Id of assort to look up * @param traderId Id of trader with assort * @returns Handbook rouble price of item */ protected getTraderItemHandbookPriceRouble(traderAssortId: string, traderId: string): number { const purchasedAssortItem = this.traderHelper.getTraderAssortItemByAssortId(traderId, traderAssortId); if (!purchasedAssortItem) { return 1; } const assortItemPriceRouble = this.handbookHelper.getTemplatePrice(purchasedAssortItem._tpl); if (!assortItemPriceRouble) { this.logger.debug( `No item price found for ${purchasedAssortItem._tpl} on trader: ${traderId} in assort: ${traderAssortId}`, ); return 1; } return assortItemPriceRouble; } /** * Receive money back after selling * @param {IPmcData} pmcData * @param {number} amountToSend * @param {IProcessSellTradeRequestData} request * @param {IItemEventRouterResponse} output * @param {string} sessionID * @returns IItemEventRouterResponse */ public giveProfileMoney( pmcData: IPmcData, amountToSend: number, request: IProcessSellTradeRequestData, output: IItemEventRouterResponse, sessionID: string, ): void { const trader = this.traderHelper.getTrader(request.tid, sessionID); const currency = this.paymentHelper.getCurrency(trader.currency); let calcAmount = this.handbookHelper.fromRUB(this.handbookHelper.inRUB(amountToSend, currency), currency); const currencyMaxStackSize = this.databaseServer.getTables().templates.items[currency]._props.StackMaxSize; let skipSendingMoneyToStash = false; for (const item of pmcData.Inventory.items) { // Item is not currency if (item._tpl !== currency) { continue; } // Item is not in the stash if (!this.inventoryHelper.isItemInStash(pmcData, item)) { continue; } // Found currency item if (item.upd.StackObjectsCount < currencyMaxStackSize) { if (item.upd.StackObjectsCount + calcAmount > currencyMaxStackSize) { // calculate difference calcAmount -= currencyMaxStackSize - item.upd.StackObjectsCount; item.upd.StackObjectsCount = currencyMaxStackSize; } else { skipSendingMoneyToStash = true; item.upd.StackObjectsCount = item.upd.StackObjectsCount + calcAmount; } // Inform client of change to items StackObjectsCount output.profileChanges[sessionID].items.change.push(item); if (skipSendingMoneyToStash) { break; } } } // Create single currency item with all currency on it const rootCurrencyReward = { _id: this.hashUtil.generate(), _tpl: currency, upd: { StackObjectsCount: Math.round(calcAmount) }, }; // Ensure money is properly split to follow its max stack size limit const rewards = this.itemHelper.splitStackIntoSeparateItems(rootCurrencyReward); if (!skipSendingMoneyToStash) { const addItemToStashRequest: IAddItemsDirectRequest = { itemsWithModsToAdd: rewards, foundInRaid: false, callback: null, useSortingTable: true, }; this.inventoryHelper.addItemsToStash(sessionID, addItemToStashRequest, pmcData, output); } // Calcualte new total sale sum with trader item sold to const saleSum = pmcData.TradersInfo[request.tid].salesSum + amountToSend; pmcData.TradersInfo[request.tid].salesSum = saleSum; this.traderHelper.lvlUp(request.tid, pmcData); } /** * Remove currency from player stash/inventory and update client object with changes * @param pmcData Player profile to find and remove currency from * @param currencyTpl Type of currency to pay * @param amountToPay money value to pay * @param sessionID Session id * @param output output object to send to client */ public addPaymentToOutput( pmcData: IPmcData, currencyTpl: string, amountToPay: number, sessionID: string, output: IItemEventRouterResponse, ): void { const moneyItemsInInventory = this.getSortedMoneyItemsInInventory( pmcData, currencyTpl, pmcData.Inventory.stash, ); const amountAvailable = moneyItemsInInventory.reduce( (accumulator, item) => accumulator + item.upd.StackObjectsCount, 0, ); // If no money in inventory or amount is not enough we return false if (moneyItemsInInventory.length <= 0 || amountAvailable < amountToPay) { this.logger.error( this.localisationService.getText("payment-not_enough_money_to_complete_transation", { amountToPay: amountToPay, amountAvailable: amountAvailable, }), ); this.httpResponse.appendErrorToOutput( output, this.localisationService.getText("payment-not_enough_money_to_complete_transation_short"), BackendErrorCodes.UNKNOWN_TRADING_ERROR, ); return; } let leftToPay = amountToPay; for (const profileMoneyItem of moneyItemsInInventory) { const itemAmount = profileMoneyItem.upd.StackObjectsCount; if (leftToPay >= itemAmount) { leftToPay -= itemAmount; this.inventoryHelper.removeItem(pmcData, profileMoneyItem._id, sessionID, output); } else { profileMoneyItem.upd.StackObjectsCount -= leftToPay; leftToPay = 0; output.profileChanges[sessionID].items.change.push(profileMoneyItem); } if (leftToPay === 0) { break; } } } /** * Get all money stacks in inventory and prioritise items in stash * @param pmcData * @param currencyTpl * @param playerStashId Players stash id * @returns Sorting money items */ protected getSortedMoneyItemsInInventory(pmcData: IPmcData, currencyTpl: string, playerStashId: string): Item[] { const moneyItemsInInventory = this.itemHelper.findBarterItems("tpl", pmcData.Inventory.items, currencyTpl); if (moneyItemsInInventory?.length === 0) { this.logger.debug(`No ${currencyTpl} money items found in inventory`); } // Prioritise items in stash to top of array moneyItemsInInventory.sort((a, b) => this.prioritiseStashSort(a, b, pmcData.Inventory.items, playerStashId)); return moneyItemsInInventory; } /** * Prioritise player stash first over player inventory * Post-raid healing would often take money out of the players pockets/secure container * @param a First money stack item * @param b Second money stack item * @param inventoryItems players inventory items * @param playerStashId Players stash id * @returns sort order */ protected prioritiseStashSort(a: Item, b: Item, inventoryItems: Item[], playerStashId: string): number { // a in stash, prioritise if (a.slotId === "hideout" && b.slotId !== "hideout") { return -1; } // b in stash, prioritise if (a.slotId !== "hideout" && b.slotId === "hideout") { return 1; } // both in containers if (a.slotId === "main" && b.slotId === "main") { // Item is in inventory, not stash, deprioritise const aInStash = this.isInStash(a.parentId, inventoryItems, playerStashId); const bInStash = this.isInStash(b.parentId, inventoryItems, playerStashId); // a in stash, prioritise if (aInStash && !bInStash) { return -1; } // b in stash, prioritise if (!aInStash && bInStash) { return 1; } } // they match return 0; } /** * Recursivly check items parents to see if it is inside the players inventory, not stash * @param itemId item id to check * @param inventoryItems player inventory * @param playerStashId Players stash id * @returns true if its in inventory */ protected isInStash(itemId: string, inventoryItems: Item[], playerStashId: string): boolean { const itemParent = inventoryItems.find((x) => x._id === itemId); if (itemParent) { if (itemParent.slotId === "hideout") { return true; } if (itemParent._id === playerStashId) { return true; } return this.isInStash(itemParent.parentId, inventoryItems, playerStashId); } return false; } }