488 lines
10 KiB
JavaScript
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;
|