import mongoose from "mongoose"; /** * Trade Model * Tracks Steam trade offers and their states */ const tradeSchema = new mongoose.Schema( { // Trade offer ID from Steam offerId: { type: String, required: true, unique: true, index: true, }, // User who initiated the trade userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true, }, steamId: { type: String, required: true, index: true, }, // Trade state state: { type: String, enum: [ "pending", // Trade offer sent, awaiting user acceptance "accepted", // User accepted, items transferred "declined", // User declined the offer "expired", // Trade offer expired "canceled", // Trade was canceled (by us or user) "failed", // Trade failed (invalid items, error, etc.) "escrow", // Trade in escrow ], default: "pending", index: true, }, // Items involved in the trade items: [ { assetId: { type: String, required: true }, name: { type: String, required: true }, image: { type: String }, game: { type: String, enum: ["cs2", "rust"], required: true }, price: { type: Number, required: true }, marketPrice: { type: Number }, category: { type: String }, rarity: { type: String }, wear: { type: String }, statTrak: { type: Boolean, default: false }, souvenir: { type: Boolean, default: false }, phase: { type: String }, }, ], // Financial information totalValue: { type: Number, required: true, min: 0, }, fee: { type: Number, default: 0, }, feePercentage: { type: Number, default: 0, }, userReceives: { type: Number, required: true, }, // User's trade URL used tradeUrl: { type: String, required: true, }, // Steam trade offer URL tradeOfferUrl: { type: String, }, // Verification code (shown on site and in trade message) verificationCode: { type: String, required: true, index: true, }, // Timestamps sentAt: { type: Date, default: Date.now, index: true, }, acceptedAt: { type: Date, }, completedAt: { type: Date, }, failedAt: { type: Date, }, expiresAt: { type: Date, }, // Error information errorMessage: { type: String, }, errorCode: { type: String, }, // Transaction reference (created after trade completes) transactionId: { type: mongoose.Schema.Types.ObjectId, ref: "Transaction", }, // Session tracking sessionId: { type: mongoose.Schema.Types.ObjectId, ref: "Session", }, // Bot information botId: { type: String, index: true, }, botUsername: { type: String, }, // Retry tracking retryCount: { type: Number, default: 0, }, lastRetryAt: { type: Date, }, // Metadata metadata: { type: mongoose.Schema.Types.Mixed, }, // Notes (internal use) notes: { type: String, }, }, { timestamps: true, collection: "trades", } ); // Indexes for common queries tradeSchema.index({ userId: 1, state: 1 }); tradeSchema.index({ state: 1, sentAt: -1 }); tradeSchema.index({ steamId: 1, state: 1 }); tradeSchema.index({ sessionId: 1 }); tradeSchema.index({ createdAt: -1 }); // Virtual for formatted total value tradeSchema.virtual("formattedTotal").get(function () { return `$${this.totalValue.toFixed(2)}`; }); // Virtual for formatted user receives tradeSchema.virtual("formattedUserReceives").get(function () { return `$${this.userReceives.toFixed(2)}`; }); // Virtual for item count tradeSchema.virtual("itemCount").get(function () { return this.items.length; }); // Virtual for time elapsed tradeSchema.virtual("timeElapsed").get(function () { const now = new Date(); const start = this.sentAt || this.createdAt; const diff = now - start; const minutes = Math.floor(diff / 60000); if (minutes < 1) return "Just now"; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; }); // Virtual for is expired tradeSchema.virtual("isExpired").get(function () { if (!this.expiresAt) return false; return new Date() > this.expiresAt; }); // Virtual for is pending tradeSchema.virtual("isPending").get(function () { return this.state === "pending"; }); // Instance methods /** * Mark trade as accepted */ tradeSchema.methods.markAsAccepted = async function () { this.state = "accepted"; this.acceptedAt = new Date(); return await this.save(); }; /** * Mark trade as completed (after transaction created) */ tradeSchema.methods.markAsCompleted = async function (transactionId) { this.state = "accepted"; this.completedAt = new Date(); if (transactionId) { this.transactionId = transactionId; } return await this.save(); }; /** * Mark trade as failed */ tradeSchema.methods.markAsFailed = async function (errorMessage, errorCode) { this.state = "failed"; this.failedAt = new Date(); this.errorMessage = errorMessage; if (errorCode) { this.errorCode = errorCode; } return await this.save(); }; /** * Mark trade as declined */ tradeSchema.methods.markAsDeclined = async function () { this.state = "declined"; return await this.save(); }; /** * Mark trade as expired */ tradeSchema.methods.markAsExpired = async function () { this.state = "expired"; return await this.save(); }; /** * Mark trade as canceled */ tradeSchema.methods.markAsCanceled = async function () { this.state = "canceled"; return await this.save(); }; /** * Increment retry count */ tradeSchema.methods.incrementRetry = async function () { this.retryCount += 1; this.lastRetryAt = new Date(); return await this.save(); }; /** * Add note */ tradeSchema.methods.addNote = async function (note) { this.notes = this.notes ? `${this.notes}\n${note}` : note; return await this.save(); }; // Static methods /** * Create a new trade record */ tradeSchema.statics.createTrade = async function (data) { const trade = new this({ offerId: data.offerId, userId: data.userId, steamId: data.steamId, state: data.state || "pending", items: data.items, totalValue: data.totalValue, fee: data.fee || 0, feePercentage: data.feePercentage || 0, userReceives: data.userReceives, tradeUrl: data.tradeUrl, tradeOfferUrl: data.tradeOfferUrl, verificationCode: data.verificationCode, sentAt: data.sentAt || new Date(), expiresAt: data.expiresAt, sessionId: data.sessionId, botId: data.botId, botUsername: data.botUsername, metadata: data.metadata, }); return await trade.save(); }; /** * Get user's trades */ tradeSchema.statics.getUserTrades = async function (userId, options = {}) { const { limit = 50, skip = 0, state = null, startDate = null, endDate = null, } = options; const query = { userId }; if (state) query.state = state; if (startDate || endDate) { query.sentAt = {}; if (startDate) query.sentAt.$gte = new Date(startDate); if (endDate) query.sentAt.$lte = new Date(endDate); } return await this.find(query) .sort({ sentAt: -1 }) .limit(limit) .skip(skip) .populate("userId", "username steamId avatar") .populate("transactionId") .exec(); }; /** * Get trade by offer ID */ tradeSchema.statics.getByOfferId = async function (offerId) { return await this.findOne({ offerId }) .populate("userId", "username steamId avatar") .populate("transactionId") .exec(); }; /** * Get pending trades */ tradeSchema.statics.getPendingTrades = async function (limit = 100) { return await this.find({ state: "pending" }) .sort({ sentAt: -1 }) .limit(limit) .populate("userId", "username steamId avatar") .exec(); }; /** * Get expired trades that need cleanup */ tradeSchema.statics.getExpiredTrades = async function () { const now = new Date(); return await this.find({ state: "pending", expiresAt: { $lt: now }, }).exec(); }; /** * Get trade statistics */ tradeSchema.statics.getStats = async function (userId = null) { const match = userId ? { userId: new mongoose.Types.ObjectId(userId) } : {}; const stats = await this.aggregate([ { $match: match }, { $group: { _id: "$state", count: { $sum: 1 }, totalValue: { $sum: "$totalValue" }, }, }, ]); const result = { total: 0, pending: 0, accepted: 0, declined: 0, expired: 0, canceled: 0, failed: 0, totalValue: 0, acceptedValue: 0, }; stats.forEach((stat) => { result.total += stat.count; result.totalValue += stat.totalValue; if (stat._id === "accepted") { result.accepted = stat.count; result.acceptedValue = stat.totalValue; } else if (stat._id === "pending") { result.pending = stat.count; } else if (stat._id === "declined") { result.declined = stat.count; } else if (stat._id === "expired") { result.expired = stat.count; } else if (stat._id === "canceled") { result.canceled = stat.count; } else if (stat._id === "failed") { result.failed = stat.count; } }); return result; }; /** * Cleanup old completed trades */ tradeSchema.statics.cleanupOldTrades = async function (daysOld = 30) { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysOld); const result = await this.deleteMany({ state: { $in: ["accepted", "declined", "expired", "canceled", "failed"] }, completedAt: { $lt: cutoffDate }, }); return result.deletedCount; }; // Pre-save hook tradeSchema.pre("save", function (next) { // Set expires at if not set (default 10 minutes) if (!this.expiresAt && this.state === "pending") { this.expiresAt = new Date(Date.now() + 10 * 60 * 1000); } next(); }); // Ensure virtuals are included in JSON tradeSchema.set("toJSON", { virtuals: true }); tradeSchema.set("toObject", { virtuals: true }); const Trade = mongoose.model("Trade", tradeSchema); export default Trade;