Files
TurboTrades/models/Trade.js
2026-01-10 04:57:43 +00:00

488 lines
10 KiB
JavaScript

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;