371 lines
8.4 KiB
JavaScript
371 lines
8.4 KiB
JavaScript
import mongoose from "mongoose";
|
|
|
|
const transactionSchema = new mongoose.Schema(
|
|
{
|
|
// User information
|
|
userId: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
ref: "User",
|
|
required: true,
|
|
index: true,
|
|
},
|
|
steamId: {
|
|
type: String,
|
|
required: true,
|
|
index: true,
|
|
},
|
|
|
|
// Transaction type
|
|
type: {
|
|
type: String,
|
|
enum: [
|
|
"deposit",
|
|
"withdrawal",
|
|
"purchase",
|
|
"sale",
|
|
"trade",
|
|
"bonus",
|
|
"refund",
|
|
],
|
|
required: true,
|
|
index: true,
|
|
},
|
|
|
|
// Transaction status
|
|
status: {
|
|
type: String,
|
|
enum: ["pending", "completed", "failed", "cancelled", "processing"],
|
|
required: true,
|
|
default: "pending",
|
|
index: true,
|
|
},
|
|
|
|
// Amount
|
|
amount: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
|
|
// Currency (for future multi-currency support)
|
|
currency: {
|
|
type: String,
|
|
default: "USD",
|
|
},
|
|
|
|
// Balance before and after (for audit trail)
|
|
balanceBefore: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
balanceAfter: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
|
|
// Session tracking (for security)
|
|
sessionId: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
ref: "Session",
|
|
required: false, // Optional for backwards compatibility
|
|
index: true,
|
|
},
|
|
sessionIdShort: {
|
|
type: String, // Last 6 chars of session ID for display
|
|
required: false,
|
|
},
|
|
|
|
// Related entities
|
|
itemId: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
ref: "Item",
|
|
required: false, // Only for purchase/sale transactions
|
|
},
|
|
itemName: {
|
|
type: String,
|
|
required: false,
|
|
},
|
|
itemImage: {
|
|
type: String,
|
|
required: false,
|
|
},
|
|
|
|
// Payment method (for deposits/withdrawals)
|
|
paymentMethod: {
|
|
type: String,
|
|
enum: ["stripe", "paypal", "crypto", "balance", "steam", "other", null],
|
|
required: false,
|
|
},
|
|
|
|
// External payment reference
|
|
externalId: {
|
|
type: String,
|
|
required: false,
|
|
index: true,
|
|
},
|
|
|
|
// Description and notes
|
|
description: {
|
|
type: String,
|
|
required: false,
|
|
},
|
|
notes: {
|
|
type: String,
|
|
required: false, // Internal notes (not visible to user)
|
|
},
|
|
|
|
// Fee information
|
|
fee: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
feePercentage: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
|
|
// Timestamps
|
|
completedAt: {
|
|
type: Date,
|
|
required: false,
|
|
},
|
|
failedAt: {
|
|
type: Date,
|
|
required: false,
|
|
},
|
|
cancelledAt: {
|
|
type: Date,
|
|
required: false,
|
|
},
|
|
|
|
// Error information (if failed)
|
|
errorMessage: {
|
|
type: String,
|
|
required: false,
|
|
},
|
|
errorCode: {
|
|
type: String,
|
|
required: false,
|
|
},
|
|
|
|
// Metadata (flexible field for additional data)
|
|
metadata: {
|
|
type: mongoose.Schema.Types.Mixed,
|
|
required: false,
|
|
},
|
|
},
|
|
{
|
|
timestamps: true, // Adds createdAt and updatedAt
|
|
collection: "transactions",
|
|
}
|
|
);
|
|
|
|
// Indexes for common queries
|
|
transactionSchema.index({ userId: 1, createdAt: -1 });
|
|
transactionSchema.index({ steamId: 1, createdAt: -1 });
|
|
transactionSchema.index({ type: 1, status: 1 });
|
|
transactionSchema.index({ sessionId: 1, createdAt: -1 });
|
|
transactionSchema.index({ status: 1, createdAt: -1 });
|
|
|
|
// Virtual for formatted amount
|
|
transactionSchema.virtual("formattedAmount").get(function () {
|
|
return `$${this.amount.toFixed(2)}`;
|
|
});
|
|
|
|
// Virtual for transaction direction (+ or -)
|
|
transactionSchema.virtual("direction").get(function () {
|
|
const positiveTypes = ["deposit", "sale", "bonus", "refund"];
|
|
return positiveTypes.includes(this.type) ? "+" : "-";
|
|
});
|
|
|
|
// Virtual for session color (deterministic based on sessionIdShort)
|
|
transactionSchema.virtual("sessionColor").get(function () {
|
|
if (!this.sessionIdShort) return "#64748b";
|
|
|
|
let hash = 0;
|
|
for (let i = 0; i < this.sessionIdShort.length; i++) {
|
|
hash = this.sessionIdShort.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
|
|
const hue = Math.abs(hash) % 360;
|
|
const saturation = 60 + (Math.abs(hash) % 20);
|
|
const lightness = 45 + (Math.abs(hash) % 15);
|
|
|
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
});
|
|
|
|
// Instance methods
|
|
|
|
/**
|
|
* Mark transaction as completed
|
|
*/
|
|
transactionSchema.methods.complete = async function () {
|
|
this.status = "completed";
|
|
this.completedAt = new Date();
|
|
return await this.save();
|
|
};
|
|
|
|
/**
|
|
* Mark transaction as failed
|
|
*/
|
|
transactionSchema.methods.fail = async function (errorMessage, errorCode) {
|
|
this.status = "failed";
|
|
this.failedAt = new Date();
|
|
this.errorMessage = errorMessage;
|
|
this.errorCode = errorCode;
|
|
return await this.save();
|
|
};
|
|
|
|
/**
|
|
* Mark transaction as cancelled
|
|
*/
|
|
transactionSchema.methods.cancel = async function () {
|
|
this.status = "cancelled";
|
|
this.cancelledAt = new Date();
|
|
return await this.save();
|
|
};
|
|
|
|
/**
|
|
* Get session ID short (last 6 chars)
|
|
*/
|
|
transactionSchema.methods.getSessionIdShort = function () {
|
|
if (!this.sessionId) return "SYSTEM";
|
|
return this.sessionId.toString().slice(-6).toUpperCase();
|
|
};
|
|
|
|
// Static methods
|
|
|
|
/**
|
|
* Create a new transaction with session tracking
|
|
*/
|
|
transactionSchema.statics.createTransaction = async function (data) {
|
|
const transaction = new this({
|
|
userId: data.userId,
|
|
steamId: data.steamId,
|
|
type: data.type,
|
|
status: data.status || "completed",
|
|
amount: data.amount,
|
|
currency: data.currency || "USD",
|
|
balanceBefore: data.balanceBefore || 0,
|
|
balanceAfter: data.balanceAfter || 0,
|
|
sessionId: data.sessionId,
|
|
sessionIdShort: data.sessionId
|
|
? data.sessionId.toString().slice(-6).toUpperCase()
|
|
: "SYSTEM",
|
|
itemId: data.itemId,
|
|
itemName: data.itemName,
|
|
paymentMethod: data.paymentMethod,
|
|
description: data.description,
|
|
notes: data.notes,
|
|
fee: data.fee || 0,
|
|
feePercentage: data.feePercentage || 0,
|
|
metadata: data.metadata,
|
|
});
|
|
|
|
return await transaction.save();
|
|
};
|
|
|
|
/**
|
|
* Get user's transaction history
|
|
*/
|
|
transactionSchema.statics.getUserTransactions = async function (
|
|
userId,
|
|
options = {}
|
|
) {
|
|
const {
|
|
limit = 50,
|
|
skip = 0,
|
|
type = null,
|
|
status = null,
|
|
startDate = null,
|
|
endDate = null,
|
|
} = options;
|
|
|
|
const query = { userId };
|
|
|
|
if (type) query.type = type;
|
|
if (status) query.status = status;
|
|
if (startDate || endDate) {
|
|
query.createdAt = {};
|
|
if (startDate) query.createdAt.$gte = new Date(startDate);
|
|
if (endDate) query.createdAt.$lte = new Date(endDate);
|
|
}
|
|
|
|
return await this.find(query)
|
|
.sort({ createdAt: -1 })
|
|
.limit(limit)
|
|
.skip(skip)
|
|
.populate("sessionId", "device browser os ip")
|
|
.exec();
|
|
};
|
|
|
|
/**
|
|
* Get transactions by session
|
|
*/
|
|
transactionSchema.statics.getSessionTransactions = async function (sessionId) {
|
|
return await this.find({ sessionId })
|
|
.sort({ createdAt: -1 })
|
|
.populate("itemId", "name rarity game")
|
|
.exec();
|
|
};
|
|
|
|
/**
|
|
* Get user's transaction statistics
|
|
*/
|
|
transactionSchema.statics.getUserStats = async function (userId) {
|
|
const stats = await this.aggregate([
|
|
{ $match: { userId: new mongoose.Types.ObjectId(userId) } },
|
|
{
|
|
$group: {
|
|
_id: "$type",
|
|
count: { $sum: 1 },
|
|
totalAmount: { $sum: "$amount" },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = {
|
|
totalDeposits: 0,
|
|
totalWithdrawals: 0,
|
|
totalPurchases: 0,
|
|
totalSales: 0,
|
|
depositCount: 0,
|
|
withdrawalCount: 0,
|
|
purchaseCount: 0,
|
|
saleCount: 0,
|
|
};
|
|
|
|
stats.forEach((stat) => {
|
|
if (stat._id === "deposit") {
|
|
result.totalDeposits = stat.totalAmount;
|
|
result.depositCount = stat.count;
|
|
} else if (stat._id === "withdrawal") {
|
|
result.totalWithdrawals = stat.totalAmount;
|
|
result.withdrawalCount = stat.count;
|
|
} else if (stat._id === "purchase") {
|
|
result.totalPurchases = stat.totalAmount;
|
|
result.purchaseCount = stat.count;
|
|
} else if (stat._id === "sale") {
|
|
result.totalSales = stat.totalAmount;
|
|
result.saleCount = stat.count;
|
|
}
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
// Pre-save hook to set sessionIdShort
|
|
transactionSchema.pre("save", function (next) {
|
|
if (this.sessionId && !this.sessionIdShort) {
|
|
this.sessionIdShort = this.sessionId.toString().slice(-6).toUpperCase();
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Ensure virtuals are included in JSON
|
|
transactionSchema.set("toJSON", { virtuals: true });
|
|
transactionSchema.set("toObject", { virtuals: true });
|
|
|
|
const Transaction = mongoose.model("Transaction", transactionSchema);
|
|
|
|
export default Transaction;
|