first commit
This commit is contained in:
487
models/Trade.js
Normal file
487
models/Trade.js
Normal file
@@ -0,0 +1,487 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user