Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions __tests__/lib/services/investments.service.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
7 changes: 6 additions & 1 deletion app/api/pools/[poolId]/invest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
Expand All @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions docs/investment-reservations.md
Original file line number Diff line number Diff line change
@@ -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.
333 changes: 103 additions & 230 deletions lib/services/investments.service.ts

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions models/InvestmentReservation.ts
Original file line number Diff line number Diff line change
@@ -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<IInvestmentReservation>("InvestmentReservation", InvestmentReservationSchema)) as mongoose.Model<{
_id: any
[key: string]: any
}>
5 changes: 4 additions & 1 deletion models/PoolInvestment.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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[]
Expand Down Expand Up @@ -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,
Expand All @@ -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<IPoolInvestment>("PoolInvestment", PoolInvestmentSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>;
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions scripts/expire-investment-reservations.ts
Original file line number Diff line number Diff line change
@@ -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
})
Loading