/ src / models / Transaction.js
Transaction.js
  1  import mongoose from "mongoose";
  2  import networks from "@/data/crypto/networks.js";
  3  import { validateWalletAddress, validateAmount, validateTxHash, handleError } from "@/utils/cryptoUtils.js";
  4  import { sanitizeString } from "@/utils/sanitizer.js";
  5  import { sanitizeQuery } from "@/services/databaseService.js";
  6  import { logger } from "@/middleware/logging.js";
  7  import config from "@/config.js";
  8  
  9  const metadataSchema = new mongoose.Schema({
 10    lockId: { type: String, maxlength: config.limits.stringMax },
 11    serializedTransaction: { type: String, maxlength: 10000 },
 12    retryCount: { type: Number, default: 0, max: 10 },
 13    lastRetryAt: { type: Date },
 14    verificationAttempts: { type: Number, default: 0, max: 20 },
 15  }, { _id: false, strict: true });
 16  
 17  const schema = new mongoose.Schema({
 18    txId: {
 19      type: String,
 20      required: true,
 21      unique: true,
 22      index: true,
 23      maxlength: config.limits.stringMax,
 24      validate: {
 25        validator: (v) => config.patterns.txIdRegex.test(v),
 26        message: "Invalid transaction ID format",
 27      },
 28    },
 29    idempotencyKey: {
 30      type: String,
 31      maxlength: config.limits.stringMax,
 32      sparse: true,
 33      validate: {
 34        validator: (v) => !v || config.patterns.idempotencyKeyRegex.test(v),
 35        message: "Invalid idempotency key format",
 36      },
 37    },
 38    type: {
 39      type: String,
 40      enum: config.transactions.types,
 41      required: true,
 42      index: true,
 43    },
 44    userId: {
 45      type: mongoose.Schema.Types.ObjectId,
 46      ref: "User",
 47      required: true,
 48      index: true,
 49    },
 50    walletAddress: {
 51      type: String,
 52      required: true,
 53      index: true,
 54      maxlength: config.limits.stringMax,
 55      validate: {
 56        validator: function (v) {
 57          return validateWalletAddress(this.chain, v);
 58        },
 59        message: "Invalid wallet address for chain",
 60      },
 61    },
 62    currency: {
 63      type: String,
 64      required: true,
 65      index: true,
 66      maxlength: 20,
 67      uppercase: true,
 68      validate: {
 69        validator: (v) => config.patterns.currencyRegex.test(v),
 70        message: "Invalid currency format",
 71      },
 72    },
 73    chain: {
 74      type: String,
 75      required: true,
 76      index: true,
 77      enum: Object.keys(networks),
 78      lowercase: true,
 79    },
 80    amount: {
 81      type: String,
 82      required: true,
 83      validate: {
 84        validator: (v) => validateAmount(v, config.fiat.precision.cents),
 85        message: "Amount must be a valid non-negative number",
 86      },
 87    },
 88    verifiedAmount: {
 89      type: String,
 90      validate: {
 91        validator: (v) => !v || validateAmount(v, config.fiat.precision.cents),
 92        message: "Verified amount must must be a valid non-negative number",
 93      },
 94    },
 95    feeAmount: {
 96      type: String,
 97      default: "0",
 98      validate: {
 99        validator: (v) => validateAmount(v, config.fiat.precision.cents),
100        message: "Fee amount must be a valid non-negative number",
101      },
102    },
103    feeCurrency: {
104      type: String,
105      maxlength: 20,
106      uppercase: true,
107    },
108    priceAtTransaction: {
109      type: Number,
110      min: 0,
111      max: 1000000000,
112    },
113    previousBalance: {
114      type: String,
115      validate: {
116        validator: (v) => !v || validateAmount(v, config.fiat.precision.cents),
117        message: "Previous balance must be a valid non-negative number",
118      },
119    },
120    newBalance: {
121      type: String,
122      validate: {
123        validator: (v) => !v || validateAmount(v, config.fiat.precision.cents),
124        message: "New balance must be a valid non-negative number",
125      },
126    },
127    onchainTxHash: {
128      type: String,
129      maxlength: config.limits.stringMax,
130      sparse: true,
131      validate: {
132        validator: function (v) {
133          if (!v) {
134            return true;
135          }
136          return validateTxHash(this.chain, v);
137        },
138        message: "Invalid transaction hash for chain",
139      },
140    },
141    escrowAddress: {
142      type: String,
143      maxlength: config.limits.stringMax,
144    },
145    blockHeight: {
146      type: Number,
147      min: 0,
148    },
149    confirmations: {
150      type: Number,
151      default: 0,
152      min: 0,
153      max: 1000000,
154    },
155    status: {
156      type: String,
157      enum: config.transactions.statuses,
158      default: "pending",
159      index: true,
160    },
161    errorCode: {
162      type: String,
163      maxlength: 64,
164      set: (v) => sanitizeString(v, 64),
165    },
166    cancelledAt: { type: Date },
167    expiredAt: { type: Date },
168    isSimulated: { type: Boolean, default: false },
169    ipAddress: {
170      type: String,
171      maxlength: 45,
172      validate: {
173        validator: (v) => !v || config.patterns.ipRegex.test(v),
174        message: "Invalid IP address format",
175      },
176    },
177    userAgent: {
178      type: String,
179      maxlength: 512,
180      set: (v) => sanitizeString(v, 512),
181    },
182    requestedAt: { type: Date },
183    confirmedAt: { type: Date },
184    metadata: {
185      type: metadataSchema,
186      default: {},
187    },
188    integrityHash: {
189      type: String,
190      maxlength: 128,
191    },
192    version: {
193      type: Number,
194      default: 1,
195    },
196  }, {
197    timestamps: true,
198    versionKey: false,
199    strict: true,
200    toJSON: {
201      transform: (doc, ret) => {
202        delete ret.ipAddress;
203        delete ret.userAgent;
204        delete ret.metadata;
205        delete ret.integrityHash;
206        delete ret.__v;
207        return ret;
208      },
209    },
210  });
211  
212  schema.index({ createdAt: -1 });
213  schema.index({ userId: 1, createdAt: -1 });
214  schema.index({ userId: 1, status: 1, createdAt: -1 });
215  schema.index({ currency: 1, chain: 1 });
216  schema.index({ type: 1, status: 1 });
217  schema.index({ status: 1, createdAt: 1 });
218  schema.index({ onchainTxHash: 1 }, { name: "onchainTxHash_idx", unique: true, sparse: true });
219  schema.index({ idempotencyKey: 1 }, { name: "idempotencyKey_idx", unique: true, sparse: true });
220  schema.index({ status: 1, expiredAt: 1 });
221  schema.index({ userId: 1, type: 1, status: 1 });
222  
223  schema.methods.calculateIntegrityHash = function () {
224    const data = `${this.txId}:${this.userId}:${this.type}:${this.amount}:${this.currency}:${this.chain}:${this.status}`;
225    return crypto.createHash("sha256").update(data).digest("hex");
226  };
227  
228  schema.methods.verifyIntegrity = function () {
229    if (!this.integrityHash) {
230      return true;
231    }
232    return this.integrityHash === this.calculateIntegrityHash();
233  };
234  
235  schema.methods.canTransition = function (newStatus) {
236    const validTransitions = {
237      pending: ["processing", "confirmed", "failed", "cancelled", "expired"],
238      processing: ["confirmed", "failed", "rolled_back"],
239      confirmed: [],
240      failed: [],
241      rolled_back: [],
242      cancelled: [],
243      expired: ["confirmed"],
244    };
245    return validTransitions[this.status]?.includes(newStatus) || false;
246  };
247  
248  schema.methods.transitionTo = async function (newStatus) {
249    try {
250      if (!this.canTransition(newStatus)) {
251        throw new Error(`Invalid status transition from ${this.status} to ${newStatus}`);
252      }
253      this.status = newStatus;
254      this.integrityHash = this.calculateIntegrityHash();
255      if (newStatus === "confirmed") {
256        this.confirmedAt = new Date();
257      } else if (newStatus === "cancelled") {
258        this.cancelledAt = new Date();
259      } else if (newStatus === "expired") {
260        this.expiredAt = new Date();
261      }
262      await this.save();
263    } catch (error) {
264      handleError(error, "Transaction.transitionTo");
265    }
266  };
267  
268  schema.pre("save", async function (next) {
269    try {
270      if (this.isNew || this.isModified("status") || this.isModified("amount")) {
271        this.integrityHash = this.calculateIntegrityHash();
272      }
273      if (this.isModified("walletAddress") && this.chain) {
274        if (!validateWalletAddress(this.chain, this.walletAddress)) {
275          return next(new Error("Invalid wallet address for chain"));
276        }
277      }
278      if (this.isModified("onchainTxHash") && this.onchainTxHash && this.chain) {
279        if (!validateTxHash(this.onchainTxHash, this.chain)) {
280          return next(new Error("Invalid transaction hash for chain"));
281        }
282      }
283      next();
284    } catch (error) {
285      handleError(error, "Transaction.pre-save");
286    }
287  });
288  
289  schema.pre("findOneAndUpdate", async function (next) {
290    try {
291      const update = this.getUpdate();
292      if (update.$set) {
293        if (update.$set.amount !== undefined && !validateAmount(update.$set.amount, config.fiat.precision.cents)) {
294          return next(new Error("Invalid amount value"));
295        }
296        if (update.$set.verifiedAmount !== undefined && !validateAmount(update.$set.verifiedAmount, config.fiat.precision.cents)) {
297          return next(new Error("Invalid verified amount value"));
298        }
299      }
300      next();
301    } catch (error) {
302      handleError(error, "Transaction.pre-findOneAndUpdate");
303    }
304  });
305  
306  schema.statics.findByTxId = async function (txId) {
307    try {
308      if (!txId || typeof txId !== "string" || !config.patterns.txIdRegex.test(txId)) {
309        return null;
310      }
311      return this.findOne({ txId });
312    } catch (error) {
313      handleError(error, "Transaction.findByTxId");
314    }
315  };
316  
317  schema.statics.findByIdempotencyKey = async function (key) {
318    try {
319      if (!key || typeof key !== "string" || key.length > config.limits.stringMax) {
320        return null;
321      }
322      return this.findOne({ idempotencyKey: key });
323    } catch (error) {
324      handleError(error, "Transaction.findByIdempotencyKey");
325    }
326  };
327  
328  schema.statics.findPendingByUser = async function (userId, limit = 10) {
329    try {
330      return this.find({ userId, status: "pending" })
331        .sort({ createdAt: -1 })
332        .limit(Math.min(limit, config.limits.queryMax))
333        .lean();
334    } catch (error) {
335      handleError(error, "Transaction.findPendingByUser");
336    }
337  };
338  
339  schema.statics.expirePendingTransactions = async function (timeoutMs = config.timeouts.transactionExpire) {
340    try {
341      if (!timeoutMs || isNaN(timeoutMs)) {
342        logger.warn("Invalid timeoutMs, returning early");
343        return { modifiedCount: 0 };
344      }
345  
346      const cutoffDate = new Date(Date.now() - timeoutMs);
347  
348      const db = mongoose.connection.db;
349      const query = { status: "pending", createdAt: { $lte: cutoffDate } };
350      const sanitizedQuery = sanitizeQuery(query);
351      const result = await db.collection("transactions").updateMany(
352        sanitizedQuery,
353        { $set: { status: "expired", expiredAt: new Date() } },
354      );
355  
356      return result;
357    } catch (error) {
358      handleError(error, "Transaction.expirePendingTransactions");
359    }
360  };
361  
362  export const Transaction = mongoose.model("Transaction", schema);