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