From 517045b370639d399dc871bc833160a607ed42c8 Mon Sep 17 00:00:00 2001 From: Okey Amy Date: Sun, 30 Aug 2026 17:46:55 +0100 Subject: [PATCH] feat(investments): add transactional pool reservations --- .../lib/services/investments.service.test.ts | 18 + app/api/pools/[poolId]/invest/route.ts | 7 +- docs/investment-reservations.md | 24 ++ lib/services/investments.service.ts | 333 ++++++------------ models/InvestmentReservation.ts | 50 +++ models/PoolInvestment.ts | 5 +- package.json | 3 +- scripts/expire-investment-reservations.ts | 13 + 8 files changed, 220 insertions(+), 233 deletions(-) create mode 100644 __tests__/lib/services/investments.service.test.ts create mode 100644 docs/investment-reservations.md create mode 100644 models/InvestmentReservation.ts create mode 100644 scripts/expire-investment-reservations.ts diff --git a/__tests__/lib/services/investments.service.test.ts b/__tests__/lib/services/investments.service.test.ts new file mode 100644 index 00000000..8d7d7969 --- /dev/null +++ b/__tests__/lib/services/investments.service.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest" +import { calculateOwnership, isValidReservationTransition } from "@/lib/services/investments.service" + +describe("pool investment reservation state machine", () => { + it("permits only explicit reservation transitions", () => { + expect(isValidReservationTransition("PENDING", "RESERVED")).toBe(true) + expect(isValidReservationTransition("RESERVED", "SETTLED")).toBe(true) + expect(isValidReservationTransition("RESERVED", "EXPIRED")).toBe(true) + expect(isValidReservationTransition("SETTLED", "EXPIRED")).toBe(false) + expect(isValidReservationTransition("EXPIRED", "SETTLED")).toBe(false) + }) + + it("allocates ownership deterministically for 20 parallel command amounts", () => { + const amounts = Array.from({ length: 20 }, () => 50_000) + const ownership = amounts.map((amount) => calculateOwnership(amount, 1_000_000)) + expect(ownership.every(({ ownershipUnits, ownershipBps }) => ownershipUnits === 50_000 && ownershipBps === 500)).toBe(true) + }) +}) diff --git a/app/api/pools/[poolId]/invest/route.ts b/app/api/pools/[poolId]/invest/route.ts index 47e60805..8dfab041 100644 --- a/app/api/pools/[poolId]/invest/route.ts +++ b/app/api/pools/[poolId]/invest/route.ts @@ -28,6 +28,10 @@ function mapInvestmentError(error: unknown): never { ]) } + if (message.includes("kyc")) { + throw ApiError.forbidden("Investor verification is required before contributing to a pool.") + } + if (message.includes("closed") || message.includes("funded") || message.includes("status")) { throw ApiError.conflict("This pool is no longer accepting contributions.") } @@ -50,13 +54,14 @@ export const POST = defineRoute({ body: PoolInvestmentRequestSchema, response: PoolInvestmentResponseSchema, successStatus: 201, - handler: async ({ user, params, body }) => { + handler: async ({ request, user, params, body }) => { try { const investment = await investInPool({ poolId: params.poolId, userId: String(user._id), amountNgn: body.amountNgn, txRef: body.txRef, + idempotencyKey: request.headers.get("Idempotency-Key") || undefined, consentAcceptanceId: body.consentAcceptanceId, jurisdiction: body.jurisdiction, role: (user.role as "driver" | "investor" | "admin") || "investor", diff --git a/docs/investment-reservations.md b/docs/investment-reservations.md new file mode 100644 index 00000000..23d6ef43 --- /dev/null +++ b/docs/investment-reservations.md @@ -0,0 +1,24 @@ +# Pool investment reservations + +The pool investment endpoint is a transactional command. Clients must send a stable +`Idempotency-Key` header for each intended investment; retries with the same key +return the already-settled investment instead of creating another position. + +```text +PENDING -> RESERVED -> SETTLED + | | \-> EXPIRED + | \----> CANCELLED | FAILED + \---------------> CANCELLED | FAILED +``` + +Terminal states have no outgoing transitions. A transaction conditionally debits +the wallet, creates the settled investment and ledger record, and increments the +pool total. If any operation fails, MongoDB rolls all of those writes back. The +expiry worker only selects `RESERVED` records, so it cannot release a settled +investment: + +```bash +npx tsx scripts/expire-investment-reservations.ts +``` + +Run that command from the scheduled worker at least once per reservation TTL. diff --git a/lib/services/investments.service.ts b/lib/services/investments.service.ts index e29e2a65..22c3e76a 100644 --- a/lib/services/investments.service.ts +++ b/lib/services/investments.service.ts @@ -1,251 +1,124 @@ +import crypto from "node:crypto" import mongoose from "mongoose" -import { - type ConsentRole, - REQUIRED_INVESTMENT_DOCUMENTS, - requireAcceptedConsent, -} from "@/lib/consent/financial-consent" +import { type ConsentRole, REQUIRED_INVESTMENT_DOCUMENTS, requireAcceptedConsent } from "@/lib/consent/financial-consent" import InvestmentPool from "@/models/InvestmentPool" import PoolInvestment from "@/models/PoolInvestment" +import InvestmentReservation, { type InvestmentReservationStatus } from "@/models/InvestmentReservation" import Transaction from "@/models/Transaction" import User from "@/models/User" - -export const TOTAL_OWNERSHIP_UNITS = 1_000_000 - -interface OwnershipResult { - ownershipUnits: number - ownershipBps: number -} - + +export const TOTAL_OWNERSHIP_UNITS = 1_000_000 +export const INVESTMENT_RESERVATION_TTL_MS = 5 * 60 * 1000 + +const TERMINAL_RESERVATION_STATES = new Set(["SETTLED", "EXPIRED", "CANCELLED", "FAILED"]) +const RESERVATION_TRANSITIONS: Record = { + PENDING: ["RESERVED", "FAILED", "CANCELLED"], + RESERVED: ["SETTLED", "EXPIRED", "CANCELLED", "FAILED"], + SETTLED: [], + EXPIRED: [], + CANCELLED: [], + FAILED: [], +} + +interface OwnershipResult { ownershipUnits: number; ownershipBps: number } interface InvestInPoolInput { - poolId: string - userId: string - amountNgn: number - txRef?: string - consentAcceptanceId?: string - jurisdiction?: string - role?: ConsentRole + poolId: string; userId: string; amountNgn: number; txRef?: string; idempotencyKey?: string + consentAcceptanceId?: string; jurisdiction?: string; role?: ConsentRole } - export interface InvestInPoolResult { - poolId: string - userId: string - amountNgn: number - ownershipUnits: number - ownershipBps: number - txRef: string - consentAcceptanceId: string - acceptedDocumentSetHash: string - poolStatus: "OPEN" | "FUNDED" | "CLOSED" - currentRaisedNgn: number - targetAmountNgn: number - investorCount: number - userBalanceNgn: number + poolId: string; userId: string; amountNgn: number; ownershipUnits: number; ownershipBps: number; txRef: string + consentAcceptanceId: string; acceptedDocumentSetHash: string; poolStatus: "OPEN" | "FUNDED" | "CLOSED" + currentRaisedNgn: number; targetAmountNgn: number; investorCount: number; userBalanceNgn: number } -const TRANSACTION_RETRY_LIMIT = 1 - +export function isValidReservationTransition(from: InvestmentReservationStatus, to: InvestmentReservationStatus) { + return RESERVATION_TRANSITIONS[from].includes(to) +} +export function calculateOwnership(amountNgn: number, targetAmountNgn: number): OwnershipResult { + return { ownershipUnits: Math.max(Math.floor((amountNgn * TOTAL_OWNERSHIP_UNITS) / targetAmountNgn), 0), ownershipBps: Math.max(Math.floor((amountNgn * 10_000) / targetAmountNgn), 0) } +} function shouldRetryMongoTransaction(error: unknown) { - if (!error || typeof error !== "object") return false + const value = error as { code?: number; codeName?: string; errorLabels?: string[]; message?: string } | null + return Boolean(value && (value.code === 251 || value.codeName === "NoSuchTransaction" || value.errorLabels?.includes("TransientTransactionError") || /does not match any in-progress transactions/i.test(value.message || ""))) +} +function idempotencyKeyFor(input: InvestInPoolInput) { return input.idempotencyKey?.trim() || input.txRef?.trim() || crypto.randomUUID() } +function isDuplicateKeyError(error: unknown) { return (error as { code?: number } | null)?.code === 11000 } + +async function resultForReservation(reservation: any): Promise { + if (reservation.status !== "SETTLED" || !reservation.poolInvestmentId) return null + const [investment, pool, user] = await Promise.all([ + PoolInvestment.findById(reservation.poolInvestmentId).lean(), InvestmentPool.findById(reservation.poolId).lean(), User.findById(reservation.userId).lean(), + ]) + if (!investment || !pool || !user) throw new Error("Investment reservation is incomplete.") + return { poolId: String(pool._id), userId: String(user._id), amountNgn: investment.amountNgn, ownershipUnits: investment.ownershipUnits, ownershipBps: investment.ownershipBps, txRef: investment.txRef, consentAcceptanceId: investment.consentAcceptanceId, acceptedDocumentSetHash: investment.acceptedDocumentSetHash, poolStatus: pool.status, currentRaisedNgn: pool.currentRaisedNgn, targetAmountNgn: pool.targetAmountNgn, investorCount: pool.investorCount, userBalanceNgn: user.availableBalance } +} - const maybeMongoError = error as { - code?: number - codeName?: string - errorLabels?: string[] - message?: string +/** + * Settles a pool investment command in one MongoDB transaction. Conditional + * debits and capacity updates make the database, rather than a stale read, the + * arbiter of wallet balance and final pool capacity. + */ +export async function investInPool(input: InvestInPoolInput): Promise { + const { poolId, userId, amountNgn, consentAcceptanceId, jurisdiction = "NG", role = "investor" } = input + if (!mongoose.Types.ObjectId.isValid(poolId)) throw new Error("Invalid pool ID.") + if (!mongoose.Types.ObjectId.isValid(userId)) throw new Error("Invalid user ID.") + if (!Number.isFinite(amountNgn) || amountNgn <= 0) throw new Error("Amount must be greater than zero.") + const idempotencyKey = idempotencyKeyFor(input) + const existing = await InvestmentReservation.findOne({ userId, idempotencyKey }).lean() + if (existing) { + const prior = await resultForReservation(existing) + if (prior) return prior + throw new Error("An investment with this idempotency key is still being processed.") } - const labels = Array.isArray(maybeMongoError.errorLabels) ? maybeMongoError.errorLabels : [] - const message = typeof maybeMongoError.message === "string" ? maybeMongoError.message : "" - - return ( - maybeMongoError.code === 251 || - maybeMongoError.codeName === "NoSuchTransaction" || - labels.includes("TransientTransactionError") || - /does not match any in-progress transactions/i.test(message) - ) -} - -export function calculateOwnership(amountNgn: number, targetAmountNgn: number): OwnershipResult { - const ownershipUnits = Math.floor((amountNgn * TOTAL_OWNERSHIP_UNITS) / targetAmountNgn) - const ownershipBps = Math.floor((amountNgn * 10_000) / targetAmountNgn) - - return { - ownershipUnits: Math.max(ownershipUnits, 0), - ownershipBps: Math.max(ownershipBps, 0), - } -} - -export async function investInPool({ - poolId, - userId, - amountNgn, - txRef, - consentAcceptanceId, - jurisdiction = "NG", - role = "investor", -}: InvestInPoolInput): Promise { - if (!mongoose.Types.ObjectId.isValid(poolId)) { - throw new Error("Invalid pool ID.") - } - - if (!mongoose.Types.ObjectId.isValid(userId)) { - throw new Error("Invalid user ID.") - } - - if (!Number.isFinite(amountNgn) || amountNgn <= 0) { - throw new Error("Amount must be greater than zero.") - } - - let attempt = 0 - const generatedTxRef = txRef || `pool_${poolId}_${Date.now()}` - - while (attempt <= TRANSACTION_RETRY_LIMIT) { + for (let attempt = 0; attempt < 3; attempt += 1) { const session = await mongoose.startSession() - session.startTransaction() - try { - // Mongo transactions do not support parallel operations on the same session. - const pool = await InvestmentPool.findById(poolId).session(session) - const user = await User.findById(userId).session(session) - - if (!pool) throw new Error("Pool not found.") - if (!user) throw new Error("User not found.") - - if (pool.status !== "OPEN") { - throw new Error("This pool is not open for investment.") - } - - if (amountNgn < pool.minContributionNgn) { - throw new Error(`Minimum contribution is ${pool.minContributionNgn}.`) - } - - const remainingAmountNgn = pool.targetAmountNgn - pool.currentRaisedNgn - if (remainingAmountNgn <= 0) { - throw new Error("This pool has already reached its target amount.") - } - - if (amountNgn > remainingAmountNgn) { - throw new Error(`Amount exceeds remaining target by ${amountNgn - remainingAmountNgn}.`) - } - - if (amountNgn > (user.availableBalance || 0)) { - throw new Error("Insufficient internal wallet balance.") - } - - const { ownershipUnits, ownershipBps } = calculateOwnership(amountNgn, pool.targetAmountNgn) - const effectiveTxRef = generatedTxRef - const consent = await requireAcceptedConsent({ - userId, - role, - jurisdiction, - acceptanceId: consentAcceptanceId, - requiredDocuments: REQUIRED_INVESTMENT_DOCUMENTS, - intent: { - type: "pool_investment", - id: pool._id.toString(), - terms: { - amountNgn, - txRef: effectiveTxRef, - poolId: pool._id.toString(), - targetAmountNgn: pool.targetAmountNgn, - jurisdiction, - }, - }, - session, + let result: InvestInPoolResult | undefined + await session.withTransaction(async () => { + const expiresAt = new Date(Date.now() + INVESTMENT_RESERVATION_TTL_MS) + const reservation = await InvestmentReservation.create([{ poolId, userId, idempotencyKey, amountNgn, status: "PENDING", expiresAt }], { session }).then(([value]) => value) + const pool = await InvestmentPool.findOne({ _id: poolId, status: "OPEN", minContributionNgn: { $lte: amountNgn }, $expr: { $gte: [{ $subtract: ["$targetAmountNgn", "$currentRaisedNgn"] }, amountNgn] } }).session(session) + if (!pool) throw new Error("Pool is closed, funded, or lacks remaining capacity.") + const user = await User.findOneAndUpdate({ _id: userId, availableBalance: { $gte: amountNgn }, $or: [{ kycStatus: "approved_stage2" }, { isKycVerified: true }, { kycVerified: true }] }, { $inc: { availableBalance: -amountNgn, heldBalance: amountNgn } }, { new: true, session }) + if (!user) throw new Error("Insufficient wallet balance or investor KYC is not approved.") + reservation.status = "RESERVED" + await reservation.save({ session }) + const ownership = calculateOwnership(amountNgn, pool.targetAmountNgn) + const txRef = input.txRef?.trim() || `pool_${reservation._id}` + const consent = await requireAcceptedConsent({ userId, role, jurisdiction, acceptanceId: consentAcceptanceId, requiredDocuments: REQUIRED_INVESTMENT_DOCUMENTS, intent: { type: "pool_investment", id: String(pool._id), terms: { amountNgn, txRef, poolId: String(pool._id), targetAmountNgn: pool.targetAmountNgn, jurisdiction } }, session }) + const hasExistingInvestment = await PoolInvestment.exists({ poolId: pool._id, userId: user._id, status: "CONFIRMED" }).session(session) + const investment = await PoolInvestment.create([{ poolId: pool._id, userId: user._id, amountNgn, ...ownership, txRef, reservationId: reservation._id, consentAcceptanceId: consent.acceptanceId, acceptedDocumentSetHash: consent.documentSetHash, acceptedDocumentVersionIds: consent.documentVersionIds, status: "CONFIRMED" }], { session }).then(([value]) => value) + const updatedPool = await InvestmentPool.findOneAndUpdate({ _id: pool._id, status: "OPEN", $expr: { $gte: [{ $subtract: ["$targetAmountNgn", "$currentRaisedNgn"] }, amountNgn] } }, { $inc: { currentRaisedNgn: amountNgn, investorCount: hasExistingInvestment ? 0 : 1 } }, { new: true, session }) + if (!updatedPool) throw new Error("Pool no longer has remaining capacity.") + if (updatedPool.currentRaisedNgn >= updatedPool.targetAmountNgn) { updatedPool.status = "FUNDED"; await updatedPool.save({ session }) } + await User.updateOne({ _id: user._id, heldBalance: { $gte: amountNgn } }, { $inc: { heldBalance: -amountNgn, totalInvested: amountNgn } }, { session }) + await Transaction.create([{ userId: user._id, userType: user.role || "investor", type: "pool_investment", amount: amountNgn, currency: "NGN", method: "internal_wallet", status: "Completed", description: `${updatedPool.assetType} pool investment`, relatedId: String(pool._id), gatewayReference: txRef, metadata: { reservationId: String(reservation._id), ownershipUnits: ownership.ownershipUnits, ownershipBps: ownership.ownershipBps, consentAcceptanceId: consent.acceptanceId, acceptedDocumentSetHash: consent.documentSetHash } }], { session }) + reservation.status = "SETTLED"; reservation.poolInvestmentId = investment._id; await reservation.save({ session }) + result = { poolId: String(updatedPool._id), userId: String(user._id), amountNgn, ...ownership, txRef, consentAcceptanceId: consent.acceptanceId, acceptedDocumentSetHash: consent.documentSetHash, poolStatus: updatedPool.status, currentRaisedNgn: updatedPool.currentRaisedNgn, targetAmountNgn: updatedPool.targetAmountNgn, investorCount: updatedPool.investorCount, userBalanceNgn: user.availableBalance } }) - - const existingInvestment = await PoolInvestment.exists({ - poolId: pool._id, - userId: user._id, - status: "CONFIRMED", - }).session(session) - - await PoolInvestment.create( - [ - { - poolId: pool._id, - userId: user._id, - amountNgn, - ownershipUnits, - ownershipBps, - txRef: effectiveTxRef, - consentAcceptanceId: consent.acceptanceId, - acceptedDocumentSetHash: consent.documentSetHash, - acceptedDocumentVersionIds: consent.documentVersionIds, - status: "CONFIRMED", - }, - ], - { session }, - ) - - user.availableBalance = Math.max((user.availableBalance || 0) - amountNgn, 0) - user.totalInvested = (user.totalInvested || 0) + amountNgn - await user.save({ session }) - - pool.currentRaisedNgn += amountNgn - if (!existingInvestment) { - pool.investorCount += 1 - } - if (pool.currentRaisedNgn >= pool.targetAmountNgn) { - pool.status = "FUNDED" - } - await pool.save({ session }) - - await Transaction.create( - [ - { - userId: user._id, - userType: user.role || "investor", - type: "pool_investment", - amount: amountNgn, - currency: "NGN", - method: "internal_wallet", - status: "Completed", - description: `${pool.assetType} pool investment`, - relatedId: pool._id.toString(), - gatewayReference: generatedTxRef, - metadata: { - ownershipUnits, - ownershipBps, - consentAcceptanceId: consent.acceptanceId, - acceptedDocumentSetHash: consent.documentSetHash, - }, - }, - ], - { session }, - ) - - await session.commitTransaction() - - return { - poolId: pool._id.toString(), - userId: user._id.toString(), - amountNgn, - ownershipUnits, - ownershipBps, - txRef: generatedTxRef, - consentAcceptanceId: consent.acceptanceId, - acceptedDocumentSetHash: consent.documentSetHash, - poolStatus: pool.status, - currentRaisedNgn: pool.currentRaisedNgn, - targetAmountNgn: pool.targetAmountNgn, - investorCount: pool.investorCount, - userBalanceNgn: user.availableBalance, - } + if (result) return result } catch (error) { - await session.abortTransaction().catch(() => undefined) - - const canRetry = attempt < TRANSACTION_RETRY_LIMIT && shouldRetryMongoTransaction(error) - if (canRetry) { - attempt += 1 - continue - } - - throw error - } finally { - session.endSession() - } + if (isDuplicateKeyError(error)) { const prior = await InvestmentReservation.findOne({ userId, idempotencyKey }).lean(); const result = prior && await resultForReservation(prior); if (result) return result } + if (attempt === 2 || !shouldRetryMongoTransaction(error)) throw error + } finally { await session.endSession() } } - throw new Error("Unable to process investment transaction.") } + +/** Releases only still-reserved holds; SETTLED commands are intentionally excluded. */ +export async function expireInvestmentReservations(now = new Date()) { + const session = await mongoose.startSession(); let expired = 0 + try { await session.withTransaction(async () => { + const reservations = await InvestmentReservation.find({ status: "RESERVED", expiresAt: { $lte: now } }).session(session) + for (const reservation of reservations) { + const released = await InvestmentReservation.findOneAndUpdate({ _id: reservation._id, status: "RESERVED", expiresAt: { $lte: now } }, { $set: { status: "EXPIRED" } }, { new: true, session }) + if (!released) continue + await User.updateOne({ _id: released.userId, heldBalance: { $gte: released.amountNgn } }, { $inc: { heldBalance: -released.amountNgn, availableBalance: released.amountNgn } }, { session }) + expired += 1 + } + }) } finally { await session.endSession() } + return expired +} + +export { TERMINAL_RESERVATION_STATES } diff --git a/models/InvestmentReservation.ts b/models/InvestmentReservation.ts new file mode 100644 index 00000000..77fdf8d8 --- /dev/null +++ b/models/InvestmentReservation.ts @@ -0,0 +1,50 @@ +import mongoose, { Document, Schema } from "mongoose" + +/** + * A durable command record for a pool investment. It is deliberately kept + * separate from PoolInvestment: this document owns the temporary hold while + * PoolInvestment represents only a settled position. + */ +export type InvestmentReservationStatus = "PENDING" | "RESERVED" | "SETTLED" | "EXPIRED" | "CANCELLED" | "FAILED" + +export interface IInvestmentReservation extends Document { + poolId: Schema.Types.ObjectId + userId: Schema.Types.ObjectId + idempotencyKey: string + amountNgn: number + status: InvestmentReservationStatus + expiresAt: Date + poolInvestmentId?: Schema.Types.ObjectId + failureReason?: string + createdAt: Date + updatedAt: Date +} + +const InvestmentReservationSchema = new Schema( + { + poolId: { type: Schema.Types.ObjectId, ref: "InvestmentPool", required: true, index: true }, + userId: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true }, + idempotencyKey: { type: String, required: true, trim: true, maxlength: 128, immutable: true }, + amountNgn: { type: Number, required: true, min: 0 }, + status: { + type: String, + enum: ["PENDING", "RESERVED", "SETTLED", "EXPIRED", "CANCELLED", "FAILED"], + default: "PENDING", + index: true, + }, + expiresAt: { type: Date, required: true, index: true }, + poolInvestmentId: { type: Schema.Types.ObjectId, ref: "PoolInvestment", index: true, sparse: true }, + failureReason: { type: String, trim: true, maxlength: 200 }, + }, + { timestamps: true }, +) + +// The user scope prevents one investor's client token from affecting another. +InvestmentReservationSchema.index({ userId: 1, idempotencyKey: 1 }, { unique: true }) +InvestmentReservationSchema.index({ status: 1, expiresAt: 1 }) + +export default (mongoose.models.InvestmentReservation || + mongoose.model("InvestmentReservation", InvestmentReservationSchema)) as mongoose.Model<{ + _id: any + [key: string]: any +}> diff --git a/models/PoolInvestment.ts b/models/PoolInvestment.ts index b608ddaf..c1e33120 100644 --- a/models/PoolInvestment.ts +++ b/models/PoolInvestment.ts @@ -1,6 +1,6 @@ import mongoose, { Document, Schema } from "mongoose" -export type PoolInvestmentStatus = "PENDING" | "CONFIRMED" | "FAILED" +export type PoolInvestmentStatus = "PENDING" | "CONFIRMED" | "FAILED" export interface IPoolInvestment extends Document { poolId: Schema.Types.ObjectId @@ -9,6 +9,7 @@ export interface IPoolInvestment extends Document { ownershipUnits: number ownershipBps: number txRef: string + reservationId?: Schema.Types.ObjectId consentAcceptanceId: string acceptedDocumentSetHash: string acceptedDocumentVersionIds: Schema.Types.ObjectId[] @@ -53,6 +54,7 @@ const PoolInvestmentSchema: Schema = new Schema( index: true, trim: true, }, + reservationId: { type: Schema.Types.ObjectId, ref: "InvestmentReservation" }, consentAcceptanceId: { type: String, required: true, @@ -79,6 +81,7 @@ const PoolInvestmentSchema: Schema = new Schema( PoolInvestmentSchema.index({ poolId: 1, userId: 1, createdAt: -1 }) PoolInvestmentSchema.index({ consentAcceptanceId: 1, userId: 1 }) +PoolInvestmentSchema.index({ reservationId: 1 }, { unique: true, sparse: true }) export default (mongoose.models.PoolInvestment || mongoose.model("PoolInvestment", PoolInvestmentSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; diff --git a/package.json b/package.json index e2d27d02..16902bb0 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "restore:token": "tsx scripts/backup/run-restore.ts --generate-token", "repayment:check-schedules": "tsx scripts/check-repayment-schedules.ts", "repayment:repair-schedules": "tsx scripts/check-repayment-schedules.ts --repair", - "privacy:sweep": "tsx scripts/privacy-sweep.ts" + "privacy:sweep": "tsx scripts/privacy-sweep.ts", + "investments:expire-reservations": "tsx scripts/expire-investment-reservations.ts" }, "dependencies": { "@hookform/resolvers": "^3.9.1", diff --git a/scripts/expire-investment-reservations.ts b/scripts/expire-investment-reservations.ts new file mode 100644 index 00000000..a17bda5d --- /dev/null +++ b/scripts/expire-investment-reservations.ts @@ -0,0 +1,13 @@ +import dbConnect from "@/lib/dbConnect" +import { expireInvestmentReservations } from "@/lib/services/investments.service" + +async function main() { + await dbConnect() + const expired = await expireInvestmentReservations() + console.log(`Expired and released ${expired} investment reservation(s).`) +} + +main().catch((error) => { + console.error("INVESTMENT_RESERVATION_EXPIRY_FAILED", error) + process.exitCode = 1 +})