diff --git a/__tests__/lib/pools/pool-lifecycle.test.ts b/__tests__/lib/pools/pool-lifecycle.test.ts new file mode 100644 index 00000000..ed804e1a --- /dev/null +++ b/__tests__/lib/pools/pool-lifecycle.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest" +import { + isPoolAcceptingInvestments, + resolvePoolStatus, +} from "@/lib/services/pool-lifecycle.service" +import type { PoolStatus } from "@/models/InvestmentPool" + +describe("pool lifecycle — resolvePoolStatus", () => { + it("normalises legacy OPEN to funding", () => { + expect(resolvePoolStatus("OPEN")).toBe("funding") + }) + + it("normalises legacy FUNDED to fully_funded", () => { + expect(resolvePoolStatus("FUNDED")).toBe("fully_funded") + }) + + it("normalises legacy CLOSED to completed", () => { + expect(resolvePoolStatus("CLOSED")).toBe("completed") + }) + + it("passes canonical statuses through unchanged", () => { + const statuses: PoolStatus[] = [ + "draft", "review", "funding", "fully_funded", + "closing", "active", "paused", "completed", + "cancelled", "archived", + ] + for (const s of statuses) { + expect(resolvePoolStatus(s)).toBe(s) + } + }) +}) + +describe("pool lifecycle — isPoolAcceptingInvestments", () => { + it("returns true only for funding status", () => { + expect(isPoolAcceptingInvestments("funding")).toBe(true) + expect(isPoolAcceptingInvestments("OPEN")).toBe(true) // legacy alias + }) + + it("returns false for non-funding statuses", () => { + const closed: PoolStatus[] = [ + "draft", "review", "fully_funded", "closing", + "active", "paused", "completed", "cancelled", "archived", + "FUNDED", "CLOSED", + ] + for (const s of closed) { + expect(isPoolAcceptingInvestments(s)).toBe(false) + } + }) +}) diff --git a/__tests__/lib/services/webhook-inbox.service.test.ts b/__tests__/lib/services/webhook-inbox.service.test.ts new file mode 100644 index 00000000..dff316eb --- /dev/null +++ b/__tests__/lib/services/webhook-inbox.service.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment node +/** + * Unit tests for webhook-inbox.service.ts + * + * These tests exercise the pure logic that does not require a live database: + * payload hashing, retry scheduling, and the exported helper functions. + */ +import { describe, it, expect } from "vitest" +import { hashPayload, computeNextRetryAt } from "@/models/WebhookInboxEvent" + +describe("hashPayload", () => { + it("returns a 64-character hex string (SHA-256)", () => { + const hash = hashPayload('{"event":"charge.success"}') + expect(hash).toMatch(/^[0-9a-f]{64}$/) + }) + + it("produces the same hash for identical payloads (deterministic)", () => { + const payload = '{"event":"charge.success","data":{"id":1}}' + expect(hashPayload(payload)).toBe(hashPayload(payload)) + }) + + it("produces different hashes for different payloads", () => { + expect(hashPayload('{"amount":100}')).not.toBe(hashPayload('{"amount":200}')) + }) +}) + +describe("computeNextRetryAt", () => { + it("schedules first retry ~30 seconds in the future", () => { + const before = Date.now() + const retryAt = computeNextRetryAt(1) + const after = Date.now() + const delayMs = retryAt.getTime() - before + // Allow up to 200 ms of execution time + expect(delayMs).toBeGreaterThanOrEqual(29_800) + expect(delayMs).toBeLessThanOrEqual(30_200 + (after - before)) + }) + + it("uses exponential backoff — each attempt is longer than the last", () => { + const delays = [1, 2, 3, 4].map( + (attempt) => computeNextRetryAt(attempt).getTime() - Date.now(), + ) + for (let i = 1; i < delays.length; i++) { + expect(delays[i]).toBeGreaterThan(delays[i - 1]) + } + }) + + it("caps the delay at 2 hours (7200 seconds)", () => { + const retryAt = computeNextRetryAt(10) // well past the cap + const delayMs = retryAt.getTime() - Date.now() + expect(delayMs).toBeLessThanOrEqual(7_200_000 + 200) + }) +}) diff --git a/__tests__/models/WebhookInboxEvent.test.ts b/__tests__/models/WebhookInboxEvent.test.ts new file mode 100644 index 00000000..61a4df20 --- /dev/null +++ b/__tests__/models/WebhookInboxEvent.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest" +import { hashPayload, computeNextRetryAt } from "@/models/WebhookInboxEvent" + +describe("WebhookInboxEvent helpers", () => { + it("hashPayload returns a 64-char hex string", () => { + const hash = hashPayload('{"event":"charge.success"}') + expect(hash).toMatch(/^[0-9a-f]{64}$/) + }) + + it("hashPayload is deterministic", () => { + const body = '{"event":"charge.success","data":{"reference":"ref_001"}}' + expect(hashPayload(body)).toBe(hashPayload(body)) + }) + + it("hashPayload differs for different payloads", () => { + expect(hashPayload("abc")).not.toBe(hashPayload("def")) + }) + + it("computeNextRetryAt returns a date in the future", () => { + const before = Date.now() + const next = computeNextRetryAt(1) + expect(next.getTime()).toBeGreaterThan(before) + }) + + it("computeNextRetryAt uses exponential backoff (attempt 2 > attempt 1)", () => { + const retry1 = computeNextRetryAt(1) + const retry2 = computeNextRetryAt(2) + expect(retry2.getTime()).toBeGreaterThan(retry1.getTime()) + }) + + it("computeNextRetryAt caps at 2 hours", () => { + const retry10 = computeNextRetryAt(10) + const twoHoursMs = 7200 * 1000 + expect(retry10.getTime() - Date.now()).toBeLessThanOrEqual(twoHoursMs + 100) + }) +}) diff --git a/app/api/admin/pools/[poolId]/transition/route.ts b/app/api/admin/pools/[poolId]/transition/route.ts new file mode 100644 index 00000000..28aeaf37 --- /dev/null +++ b/app/api/admin/pools/[poolId]/transition/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server" + +import { getSessionFromCookies } from "@/lib/auth/session" +import dbConnect from "@/lib/dbConnect" +import { + transitionPoolStatus, +} from "@/lib/services/pool-lifecycle.service" +import User from "@/models/User" +import type { PoolStatus } from "@/models/InvestmentPool" + +async function requireAdmin(): Promise<{ id: string } | null> { + const session = await getSessionFromCookies() + if (!session?.userId) return null + await dbConnect() + const user = await User.findById(session.userId).select("role").lean() + if (!user || user.role !== "admin") return null + return { id: user._id.toString() } +} + +/** + * POST /api/admin/pools/:poolId/transition + * + * Admin-only endpoint to advance or revert a pool's lifecycle status. + * All transitions are validated against the allowed transition matrix and + * recorded in PoolTransitionHistory with a correlation ID. + * + * Body: { toStatus: PoolStatus, reason?: string } + */ +export async function POST( + request: Request, + context: { params: Promise<{ poolId: string }> }, +) { + const admin = await requireAdmin() + if (!admin) { + return NextResponse.json({ message: "Forbidden." }, { status: 403 }) + } + + const { poolId } = await context.params + + let body: { toStatus?: PoolStatus; reason?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ message: "Invalid JSON body." }, { status: 400 }) + } + + const { toStatus, reason } = body + + if (!toStatus) { + return NextResponse.json({ message: "toStatus is required." }, { status: 400 }) + } + + try { + const result = await transitionPoolStatus(poolId, toStatus as any, { + actorId: admin.id, + actorRole: "admin", + reason, + }) + return NextResponse.json({ success: true, ...result }, { status: 200 }) + } catch (error) { + const message = error instanceof Error ? error.message : "Transition failed." + return NextResponse.json({ message }, { status: 422 }) + } +} diff --git a/app/api/admin/webhook-inbox/route.ts b/app/api/admin/webhook-inbox/route.ts new file mode 100644 index 00000000..0974e55b --- /dev/null +++ b/app/api/admin/webhook-inbox/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server" + +import { getSessionFromCookies } from "@/lib/auth/session" +import dbConnect from "@/lib/dbConnect" +import { replayWebhookEvent, purgeOldWebhookEvents } from "@/lib/services/webhook-inbox.service" +import User from "@/models/User" +import WebhookInboxEvent from "@/models/WebhookInboxEvent" + +async function requireAdmin(): Promise<{ id: string } | null> { + const session = await getSessionFromCookies() + if (!session?.userId) return null + await dbConnect() + const user = await User.findById(session.userId).select("role").lean() + if (!user || user.role !== "admin") return null + return { id: user._id.toString() } +} + +/** GET /api/admin/webhook-inbox — list events with optional status filter */ +export async function GET(request: Request) { + const admin = await requireAdmin() + if (!admin) return NextResponse.json({ message: "Forbidden." }, { status: 403 }) + + const url = new URL(request.url) + const status = url.searchParams.get("status") ?? undefined + const limit = Math.min(Number(url.searchParams.get("limit") ?? "50"), 200) + + const filter: Record = {} + if (status) filter.status = status + + const events = await WebhookInboxEvent.find(filter) + .sort({ createdAt: -1 }) + .limit(limit) + .lean() + + return NextResponse.json({ events }, { status: 200 }) +} + +/** POST /api/admin/webhook-inbox — replay or purge */ +export async function POST(request: Request) { + const admin = await requireAdmin() + if (!admin) return NextResponse.json({ message: "Forbidden." }, { status: 403 }) + + const body = await request.json() + const { action, inboxId, reason, retentionDays } = body as { + action: string + inboxId?: string + reason?: string + retentionDays?: number + } + + if (action === "replay") { + if (!inboxId || !reason) { + return NextResponse.json({ message: "inboxId and reason are required for replay." }, { status: 400 }) + } + const result = await replayWebhookEvent(inboxId, { + adminUserId: admin.id, + reason, + }) + return NextResponse.json(result, { status: 200 }) + } + + if (action === "purge") { + const deleted = await purgeOldWebhookEvents(retentionDays ?? 90) + return NextResponse.json({ deleted }, { status: 200 }) + } + + return NextResponse.json({ message: `Unknown action: ${action}` }, { status: 400 }) +} diff --git a/app/api/payments/webhook/route.ts b/app/api/payments/webhook/route.ts index fb592b6a..b9edc1e0 100644 --- a/app/api/payments/webhook/route.ts +++ b/app/api/payments/webhook/route.ts @@ -7,6 +7,11 @@ import { confirmDriverPayment, createAndConfirmDriverTransferPayment } from "@/l import { getInvestorVirtualAccountByAccountNumber } from "@/lib/services/paystack-investor-dva.service" import { getDriverVirtualAccountByAccountNumber } from "@/lib/services/paystack-dva.service" import { processGatewayCharge } from "@/lib/services/paystack-processing.service" +import { + ingestWebhookEvent, + markWebhookCompleted, + markWebhookFailed, +} from "@/lib/services/webhook-inbox.service" function resolvePaymentType(metadata: Record) { const rawType = typeof metadata.paymentType === "string" ? metadata.paymentType.toLowerCase() : "wallet_funding" @@ -43,7 +48,13 @@ export async function POST(request: Request) { return NextResponse.json({ message: "Invalid signature." }, { status: 401 }) } - const event = JSON.parse(body) + let event: Record + try { + event = JSON.parse(body) + } catch { + return NextResponse.json({ message: "Malformed JSON payload." }, { status: 400 }) + } + const eventName = event.event as string const supportedEvents = [ "charge.success", @@ -58,10 +69,85 @@ export async function POST(request: Request) { return NextResponse.json({ status: "ignored" }, { status: 200 }) } - try { + // Durably persist the verified event envelope before any domain processing. + const providerEventId = (event.data as Record)?.id as string | undefined + const inboxKey = providerEventId ?? `ref:${(event.data as Record)?.reference}` + const ingestResult = await ingestWebhookEvent(inboxKey, eventName, body) + + if (ingestResult.conflicting) { + return NextResponse.json( + { status: "quarantined", correlationId: ingestResult.correlationId }, + { status: 200 }, + ) + } + + if (ingestResult.duplicate) { + // Already successfully processed — return 200 to prevent redelivery storms. + return NextResponse.json( + { status: "duplicate", correlationId: ingestResult.correlationId }, + { status: 200 }, + ) + } + + const inboxId = ingestResult.inboxId + + async function processDomain(): Promise { await dbConnect() - const charge = event.data + // --- Durable webhook inbox: persist before processing (issue #89) --- + const charge = (event.data ?? {}) as Record + const providerEventId = + typeof (charge as any).reference === "string" + ? (charge as any).reference + : `${eventName}:${Date.now()}` + const payloadHash = hashPayload(body) + const correlationId = crypto.randomUUID() + + let inboxEvent = await WebhookInboxEvent.findOne({ + provider: "paystack", + providerEventId, + payloadHash, + }) + + if (inboxEvent && inboxEvent.status === "completed") { + // Exact duplicate of an already-processed delivery — safe to ack + return NextResponse.json({ status: "already_processed", correlationId: inboxEvent.correlationId }, { status: 200 }) + } + + if (!inboxEvent) { + // Check for conflicting payload (same reference, different hash) + const conflict = await WebhookInboxEvent.findOne({ + provider: "paystack", + providerEventId, + payloadHash: { $ne: payloadHash }, + }) + if (conflict) { + // Quarantine: move conflict to dead_letter and continue to store new event for inspection + await WebhookInboxEvent.updateOne( + { _id: conflict._id }, + { $set: { status: "dead_letter", lastError: "Conflicting payload received for the same reference." } }, + ) + } + + inboxEvent = await WebhookInboxEvent.create({ + provider: "paystack", + providerEventId, + eventType: eventName, + payloadHash, + rawPayload: event, + status: "processing", + correlationId, + receivedAt: new Date(), + }) + } else { + // Retry of a previously failed event + inboxEvent.status = "processing" + inboxEvent.attempts += 1 + await inboxEvent.save() + } + // --- End inbox persist --- + + const charge = (event.data ?? {}) as Record const reference = charge.reference as string if (!reference) { return NextResponse.json({ message: "Missing transaction reference." }, { status: 400 }) @@ -181,16 +267,36 @@ export async function POST(request: Request) { processedVia: "webhook", }) + await WebhookInboxEvent.updateOne( + { _id: inboxEvent._id }, + { $set: { status: "completed", processedAt: new Date() } }, + ) + return NextResponse.json( { status: "success", alreadyProcessed: settlementResult.alreadyProcessed, type: settlementResult.type, + correlationId: inboxEvent.correlationId, }, { status: 200 }, ) + } + + let domainResponse: NextResponse + try { + domainResponse = await processDomain() + // Mark completed only on 2xx responses + if (domainResponse.status < 400) { + await markWebhookCompleted(inboxId).catch(() => undefined) + } else { + await markWebhookFailed(inboxId, `Domain returned ${domainResponse.status}`).catch(() => undefined) + } } catch (error) { console.error("PAYSTACK_WEBHOOK_ERROR", error) + const message = error instanceof Error ? error.message : "Unknown error" + await markWebhookFailed(inboxId, message).catch(() => undefined) return NextResponse.json({ message: "Error processing webhook." }, { status: 500 }) } + return domainResponse } diff --git a/lib/services/pool-lifecycle.service.ts b/lib/services/pool-lifecycle.service.ts new file mode 100644 index 00000000..9199a864 --- /dev/null +++ b/lib/services/pool-lifecycle.service.ts @@ -0,0 +1,235 @@ +/** + * Pool Lifecycle Service + * + * Enforces the vehicle investment pool state machine. All status changes + * must go through `transitionPoolStatus` — never update the pool model + * directly in other services. + * + * Transition matrix: + * draft → review | cancelled + * review → funding | cancelled + * funding → fully_funded | closing | cancelled + * fully_funded → closing | cancelled + * closing → active | cancelled + * active → paused | completed | cancelled + * paused → active | cancelled + * completed → archived + * cancelled → (terminal — no further transitions) + * archived → (terminal — no further transitions) + */ + +import mongoose from "mongoose" + +import dbConnect from "@/lib/dbConnect" +import { logAuditEvent } from "@/lib/security/audit-log" +import InvestmentPool, { type PoolStatus } from "@/models/InvestmentPool" +import PoolTransitionHistory from "@/models/PoolTransitionHistory" + +type NormalisedStatus = Exclude + +/** Normalise legacy string aliases to canonical lifecycle statuses */ +function normalise(status: PoolStatus): NormalisedStatus { + if (status === "OPEN") return "funding" + if (status === "FUNDED") return "fully_funded" + if (status === "CLOSED") return "completed" + return status as NormalisedStatus +} + +const ALLOWED_TRANSITIONS: Record = { + draft: ["review", "cancelled"], + review: ["funding", "cancelled"], + funding: ["fully_funded", "closing", "cancelled"], + fully_funded: ["closing", "cancelled"], + closing: ["active", "cancelled"], + active: ["paused", "completed", "cancelled"], + paused: ["active", "cancelled"], + completed: ["archived"], + cancelled: [], + archived: [], +} + +/** Roles that may initiate a given transition */ +const ACTOR_PERMISSIONS: Partial> = { + review: ["admin"], + funding: ["admin"], + closing: ["admin"], + active: ["admin"], + completed: ["admin"], + archived: ["admin"], + cancelled: ["admin"], + paused: ["admin"], + fully_funded: ["system", "admin"], +} + +export interface TransitionOptions { + actorId?: string + actorRole?: string + reason?: string +} + +export interface TransitionResult { + poolId: string + fromStatus: NormalisedStatus + toStatus: NormalisedStatus + correlationId: string +} + +/** + * Atomically transition a pool's status. + * + * Throws if the transition is invalid, the actor lacks permission, or + * a concurrent modification is detected (optimistic concurrency). + */ +export async function transitionPoolStatus( + poolId: string, + toStatus: NormalisedStatus, + options: TransitionOptions = {}, +): Promise { + await dbConnect() + + if (!mongoose.Types.ObjectId.isValid(poolId)) { + throw new Error(`Invalid pool ID: ${poolId}`) + } + + const pool = await InvestmentPool.findById(poolId) + if (!pool) throw new Error(`Pool not found: ${poolId}`) + + const currentStatus = normalise(pool.status) + const allowed = ALLOWED_TRANSITIONS[currentStatus] ?? [] + + if (!allowed.includes(toStatus)) { + throw new Error( + `Invalid transition: ${currentStatus} → ${toStatus}. Allowed: [${allowed.join(", ")}]`, + ) + } + + // Permission check + const requiredRoles = ACTOR_PERMISSIONS[toStatus] + if (requiredRoles && options.actorRole && !requiredRoles.includes(options.actorRole)) { + throw new Error(`Actor role '${options.actorRole}' is not permitted to initiate '${toStatus}'.`) + } + + // Precondition checks + await assertPreconditions(pool, toStatus) + + const session = await mongoose.startSession() + let correlationId = "" + + try { + await session.withTransaction(async () => { + // Optimistic concurrency: findOneAndUpdate with current __v + const updated = await InvestmentPool.findOneAndUpdate( + { _id: pool._id, __v: pool.__v }, + { + $set: { + status: toStatus, + ...(toStatus === "closing" || toStatus === "fully_funded" + ? { fundingCapacityNgn: pool.currentRaisedNgn } + : {}), + }, + $inc: { __v: 1 }, + }, + { new: true, session }, + ) + + if (!updated) { + throw new Error("Concurrent modification detected — please retry.") + } + + const historyEntry = await PoolTransitionHistory.create( + [ + { + poolId: pool._id, + fromStatus: currentStatus, + toStatus, + actorId: options.actorId + ? new mongoose.Types.ObjectId(options.actorId) + : null, + actorRole: options.actorRole ?? null, + reason: options.reason ?? null, + }, + ], + { session }, + ) + + correlationId = historyEntry[0].correlationId + }) + } finally { + await session.endSession() + } + + await logAuditEvent({ + action: "pool.status_transition", + targetType: "investment_pool", + targetId: poolId, + metadata: { + fromStatus: currentStatus, + toStatus, + actorId: options.actorId, + reason: options.reason, + correlationId, + }, + }) + + return { poolId, fromStatus: currentStatus, toStatus, correlationId } +} + +/** Domain preconditions that must hold before entering a given state */ +async function assertPreconditions( + pool: Awaited>, + toStatus: NormalisedStatus, +): Promise { + if (!pool) return + + if (toStatus === "closing" || toStatus === "fully_funded") { + if (pool.currentRaisedNgn <= 0) { + throw new Error("Cannot close funding: no investments have been made.") + } + } + + if (toStatus === "active") { + if (!pool.fundingCapacityNgn || pool.fundingCapacityNgn <= 0) { + throw new Error("Cannot activate pool: funding has not been closed/frozen.") + } + } +} + +/** Returns the normalised status so callers don't need to import normalise */ +export function resolvePoolStatus(status: PoolStatus): NormalisedStatus { + return normalise(status) +} + +/** Whether a pool is currently accepting new investments */ +export function isPoolAcceptingInvestments(status: PoolStatus): boolean { + const s = normalise(status) + return s === "funding" +} + +/** + * Expire underfunded pools whose fundingDeadline has passed. + * Intended to be called from a scheduled job. + * Returns the number of pools cancelled. + */ +export async function expireUnderfundedPools(): Promise { + await dbConnect() + + const now = new Date() + const expired = await InvestmentPool.find({ + status: { $in: ["funding", "OPEN"] }, + fundingDeadline: { $lt: now }, + }) + + let count = 0 + for (const pool of expired) { + try { + await transitionPoolStatus(pool._id.toString(), "cancelled", { + actorRole: "system", + reason: "Funding deadline expired without reaching target.", + }) + count++ + } catch { + // Log and continue — one failure must not block others + } + } + return count +} diff --git a/lib/services/pools.service.ts b/lib/services/pools.service.ts index c7dd8c0f..a4df99ed 100644 --- a/lib/services/pools.service.ts +++ b/lib/services/pools.service.ts @@ -1,7 +1,8 @@ -import mongoose from "mongoose" -import InvestmentPool, { type PoolAssetType } from "@/models/InvestmentPool" -import PoolInvestment from "@/models/PoolInvestment" -import { TOTAL_OWNERSHIP_UNITS } from "@/lib/services/investments.service" +import mongoose from "mongoose" +import InvestmentPool, { type PoolAssetType, type PoolStatus, isValidPoolTransition } from "@/models/InvestmentPool" +import PoolInvestment from "@/models/PoolInvestment" +import StateTransitionHistory from "@/models/StateTransitionHistory" +import { TOTAL_OWNERSHIP_UNITS } from "@/lib/services/investments.service" export const ASSET_PRICE_NGN: Record = { SHUTTLE: 4_600_000, @@ -16,7 +17,7 @@ interface CreatePoolInput { description?: string } -export interface PoolSummary { +export interface PoolSummary { id: string assetType: PoolAssetType assetPriceNgn: number @@ -33,23 +34,23 @@ export interface PoolSummary { updatedAt: string userOwnershipUnits?: number userOwnershipBps?: number - userInvestedNgn?: number -} - -const TOTAL_OWNERSHIP_BPS = 10_000 - -function calculateOwnershipFromAmount(amountNgn: number, targetAmountNgn: number) { - if (!Number.isFinite(amountNgn) || !Number.isFinite(targetAmountNgn) || targetAmountNgn <= 0) { - return { ownershipUnits: 0, ownershipBps: 0 } - } - - return { - ownershipUnits: Math.max(Math.floor((amountNgn * TOTAL_OWNERSHIP_UNITS) / targetAmountNgn), 0), - ownershipBps: Math.max(Math.floor((amountNgn * TOTAL_OWNERSHIP_BPS) / targetAmountNgn), 0), - } -} - -function normalizePool(pool: any): PoolSummary { + userInvestedNgn?: number +} + +const TOTAL_OWNERSHIP_BPS = 10_000 + +function calculateOwnershipFromAmount(amountNgn: number, targetAmountNgn: number) { + if (!Number.isFinite(amountNgn) || !Number.isFinite(targetAmountNgn) || targetAmountNgn <= 0) { + return { ownershipUnits: 0, ownershipBps: 0 } + } + + return { + ownershipUnits: Math.max(Math.floor((amountNgn * TOTAL_OWNERSHIP_UNITS) / targetAmountNgn), 0), + ownershipBps: Math.max(Math.floor((amountNgn * TOTAL_OWNERSHIP_BPS) / targetAmountNgn), 0), + } +} + +function normalizePool(pool: any): PoolSummary { const remainingAmountNgn = Math.max(pool.targetAmountNgn - pool.currentRaisedNgn, 0) const progressRatio = pool.targetAmountNgn > 0 ? pool.currentRaisedNgn / pool.targetAmountNgn : 0 @@ -120,7 +121,7 @@ export async function listPools(userId?: string): Promise { } const poolObjectIds = pools.map((pool) => pool._id) - const ownershipRows = await PoolInvestment.aggregate([ + const ownershipRows = await PoolInvestment.aggregate([ { $match: { poolId: { $in: poolObjectIds }, @@ -128,36 +129,36 @@ export async function listPools(userId?: string): Promise { status: "CONFIRMED", }, }, - { - $group: { - _id: "$poolId", - amountNgn: { $sum: "$amountNgn" }, - }, - }, - ]) - - const ownershipByPool = new Map( - ownershipRows.map((row) => [ - row._id.toString(), - { - amountNgn: row.amountNgn, - }, - ]), - ) - - return normalizedPools.map((pool) => { - const ownership = ownershipByPool.get(pool.id) - const amountNgn = ownership?.amountNgn ?? 0 - const computedOwnership = calculateOwnershipFromAmount(amountNgn, pool.targetAmountNgn) - - return { - ...pool, - userOwnershipUnits: computedOwnership.ownershipUnits, - userOwnershipBps: computedOwnership.ownershipBps, - userInvestedNgn: amountNgn, - } - }) -} + { + $group: { + _id: "$poolId", + amountNgn: { $sum: "$amountNgn" }, + }, + }, + ]) + + const ownershipByPool = new Map( + ownershipRows.map((row) => [ + row._id.toString(), + { + amountNgn: row.amountNgn, + }, + ]), + ) + + return normalizedPools.map((pool) => { + const ownership = ownershipByPool.get(pool.id) + const amountNgn = ownership?.amountNgn ?? 0 + const computedOwnership = calculateOwnershipFromAmount(amountNgn, pool.targetAmountNgn) + + return { + ...pool, + userOwnershipUnits: computedOwnership.ownershipUnits, + userOwnershipBps: computedOwnership.ownershipBps, + userInvestedNgn: amountNgn, + } + }) +} export async function getPoolById(poolId: string, userId?: string) { if (!mongoose.Types.ObjectId.isValid(poolId)) { @@ -176,7 +177,7 @@ export async function getPoolById(poolId: string, userId?: string) { return normalizedPool } - const ownership = await PoolInvestment.aggregate([ + const ownership = await PoolInvestment.aggregate([ { $match: { poolId: poolObjectId, @@ -184,22 +185,121 @@ export async function getPoolById(poolId: string, userId?: string) { status: "CONFIRMED", }, }, + { + $group: { + _id: "$poolId", + amountNgn: { $sum: "$amountNgn" }, + }, + }, + ]) + + const currentUserOwnership = ownership[0] + const investedAmountNgn = currentUserOwnership?.amountNgn ?? 0 + const computedOwnership = calculateOwnershipFromAmount(investedAmountNgn, normalizedPool.targetAmountNgn) + + return { + ...normalizedPool, + userOwnershipUnits: computedOwnership.ownershipUnits, + userOwnershipBps: computedOwnership.ownershipBps, + userInvestedNgn: investedAmountNgn, + } +} + +// ----------------------------------------------------------------------- +// Pool lifecycle state-machine transition (issue #90) +// ----------------------------------------------------------------------- + +export interface TransitionPoolStatusInput { + poolId: string + toStatus: PoolStatus + actorId: string + actorType?: "admin" | "system" + reason: string +} + +/** + * Atomically transitions a pool to a new lifecycle status. + * + * - Validates the transition is permitted by the state machine. + * - Uses optimistic concurrency (__v) to prevent lost-update races. + * - Appends an immutable record to StateTransitionHistory. + * - Sets lifecycle timestamp fields (closedFundingAt, activatedAt, etc.). + * - Returns the updated pool summary. + * + * Throws on invalid transition, pool not found, or concurrent modification. + */ +export async function transitionPoolStatus({ + poolId, + toStatus, + actorId, + actorType = "admin", + reason, +}: TransitionPoolStatusInput) { + if (!mongoose.Types.ObjectId.isValid(poolId)) { + throw new Error("Invalid pool ID.") + } + if (!mongoose.Types.ObjectId.isValid(actorId)) { + throw new Error("Invalid actor ID.") + } + if (!reason || !reason.trim()) { + throw new Error("A reason is required for pool status transitions.") + } + + const pool = await InvestmentPool.findById(poolId) + if (!pool) { + throw new Error("Pool not found.") + } + + const fromStatus = pool.status as PoolStatus + if (!isValidPoolTransition(fromStatus, toStatus)) { + throw new Error( + `Invalid pool transition: ${fromStatus} → ${toStatus}.`, + ) + } + + const now = new Date() + const timestampUpdate: Record = {} + if (toStatus === "closing" || toStatus === "fully_funded") { + timestampUpdate.closedFundingAt = now + } + if (toStatus === "active") { + timestampUpdate.activatedAt = now + } + if (toStatus === "completed") { + timestampUpdate.completedAt = now + } + if (toStatus === "cancelled") { + timestampUpdate.cancelledAt = now + if (reason) timestampUpdate.cancellationReason = reason as any + } + + // Optimistic concurrency: only update when __v matches what we read + const currentVersion = (pool as any).__v ?? 0 + const updated = await InvestmentPool.findOneAndUpdate( + { _id: pool._id, __v: currentVersion }, { - $group: { - _id: "$poolId", - amountNgn: { $sum: "$amountNgn" }, - }, + $set: { status: toStatus, ...timestampUpdate }, + $inc: { __v: 1 }, }, - ]) - - const currentUserOwnership = ownership[0] - const investedAmountNgn = currentUserOwnership?.amountNgn ?? 0 - const computedOwnership = calculateOwnershipFromAmount(investedAmountNgn, normalizedPool.targetAmountNgn) + { new: true }, + ) - return { - ...normalizedPool, - userOwnershipUnits: computedOwnership.ownershipUnits, - userOwnershipBps: computedOwnership.ownershipBps, - userInvestedNgn: investedAmountNgn, + if (!updated) { + throw new Error( + "Pool was modified concurrently. Please retry the transition.", + ) } + + await StateTransitionHistory.create({ + entityType: "investment", + entityId: pool._id, + fromState: fromStatus, + toState: toStatus, + actorType, + actorId: new mongoose.Types.ObjectId(actorId), + reason: reason.trim(), + timestamp: now, + }) + + return normalizePool(updated) } diff --git a/lib/services/webhook-inbox.service.ts b/lib/services/webhook-inbox.service.ts new file mode 100644 index 00000000..c9b813b8 --- /dev/null +++ b/lib/services/webhook-inbox.service.ts @@ -0,0 +1,204 @@ +/** + * Webhook Inbox Service + * + * Durably persists verified Paystack webhook events before any domain + * processing. Supports idempotent processing, exponential-backoff retries, + * dead-letter quarantine, and admin-only replay with audit trail. + */ + +import dbConnect from "@/lib/dbConnect" +import { logAuditEvent } from "@/lib/security/audit-log" +import WebhookInboxEvent, { + computeNextRetryAt, + hashPayload, + type IWebhookInboxEvent, +} from "@/models/WebhookInboxEvent" + +export interface IngestWebhookResult { + inboxId: string + correlationId: string + /** true when this exact delivery was already stored (duplicate) */ + duplicate: boolean + /** true when a different payload arrived for the same providerEventId */ + conflicting: boolean +} + +/** + * Persists the raw verified event envelope. Must be called immediately after + * signature verification, before any domain work. + * + * Returns duplicate=true when the identical delivery was already recorded so + * the caller can skip re-processing. Returns conflicting=true when a + * different payload arrived for the same providerEventId — the new event is + * stored as dead_letter for investigation. + */ +export async function ingestWebhookEvent( + providerEventId: string, + eventType: string, + rawPayload: string, +): Promise { + await dbConnect() + + const payloadHash = hashPayload(rawPayload) + + // Check for an existing record first + const existing = await WebhookInboxEvent.findOne({ providerEventId }).lean() + + if (existing) { + if (existing.payloadHash === payloadHash) { + return { + inboxId: existing._id.toString(), + correlationId: existing.correlationId, + duplicate: true, + conflicting: false, + } + } + + // Different payload for same providerEventId — quarantine as dead_letter + const conflictEvent = await WebhookInboxEvent.create({ + providerEventId: `${providerEventId}__conflict__${payloadHash.slice(0, 8)}`, + eventType, + payloadHash, + rawPayload, + status: "dead_letter", + lastError: `Conflicting payload detected for providerEventId ${providerEventId}`, + }) + + await logAuditEvent({ + action: "webhook.conflict_detected", + targetType: "webhook_event", + targetId: providerEventId, + status: "failure", + metadata: { existingHash: existing.payloadHash, incomingHash: payloadHash }, + }) + + return { + inboxId: conflictEvent._id.toString(), + correlationId: conflictEvent.correlationId, + duplicate: false, + conflicting: true, + } + } + + const event = await WebhookInboxEvent.create({ + providerEventId, + eventType, + payloadHash, + rawPayload, + status: "pending", + nextRetryAt: new Date(), + }) + + return { + inboxId: event._id.toString(), + correlationId: event.correlationId, + duplicate: false, + conflicting: false, + } +} + +/** Mark event as completed after successful domain processing */ +export async function markWebhookCompleted(inboxId: string): Promise { + await WebhookInboxEvent.findByIdAndUpdate(inboxId, { + status: "completed", + processedAt: new Date(), + nextRetryAt: null, + lastError: null, + }) +} + +/** + * Record a processing failure. Increments attempts and schedules a retry + * with exponential backoff. Moves to dead_letter once maxAttempts is reached. + */ +export async function markWebhookFailed( + inboxId: string, + errorMessage: string, +): Promise { + const event = await WebhookInboxEvent.findById(inboxId) + if (!event) return + + const attempts = event.attempts + 1 + const dead = attempts >= event.maxAttempts + + await WebhookInboxEvent.findByIdAndUpdate(inboxId, { + status: dead ? "dead_letter" : "failed", + attempts, + lastError: errorMessage, + nextRetryAt: dead ? null : computeNextRetryAt(attempts), + }) + + if (dead) { + await logAuditEvent({ + action: "webhook.dead_lettered", + targetType: "webhook_event", + targetId: event.providerEventId, + status: "failure", + metadata: { inboxId, attempts, lastError: errorMessage }, + }) + } +} + +export interface ReplayWebhookOptions { + adminUserId: string + reason: string +} + +/** + * Admin-only replay. Resets a dead_letter or failed event back to pending so + * it will be picked up on the next processing cycle. Idempotent — replaying + * a completed event is a no-op and returns false. + */ +export async function replayWebhookEvent( + inboxId: string, + { adminUserId, reason }: ReplayWebhookOptions, +): Promise<{ replayed: boolean; alreadyCompleted: boolean }> { + const event = await WebhookInboxEvent.findById(inboxId) + if (!event) throw new Error(`Webhook inbox event not found: ${inboxId}`) + + if (event.status === "completed") { + return { replayed: false, alreadyCompleted: true } + } + + await WebhookInboxEvent.findByIdAndUpdate(inboxId, { + status: "pending", + attempts: 0, + lastError: null, + nextRetryAt: new Date(), + replayedAt: new Date(), + replayedBy: adminUserId, + replayReason: reason, + }) + + await logAuditEvent({ + action: "webhook.replayed", + targetType: "webhook_event", + targetId: event.providerEventId, + metadata: { inboxId, adminUserId, reason }, + }) + + return { replayed: true, alreadyCompleted: false } +} + +/** Fetch events ready for retry (pending / failed with nextRetryAt <= now) */ +export async function fetchRetryableWebhookEvents( + limit = 20, +): Promise { + return WebhookInboxEvent.find({ + status: { $in: ["pending", "failed"] }, + nextRetryAt: { $lte: new Date() }, + }) + .sort({ nextRetryAt: 1 }) + .limit(limit) + .lean() as Promise +} + +/** Purge completed events older than `retentionDays` days */ +export async function purgeOldWebhookEvents(retentionDays = 90): Promise { + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000) + const result = await WebhookInboxEvent.deleteMany({ + status: "completed", + processedAt: { $lt: cutoff }, + }) + return result.deletedCount ?? 0 +} diff --git a/models/InvestmentPool.ts b/models/InvestmentPool.ts index bcc3bc94..6e3366c1 100644 --- a/models/InvestmentPool.ts +++ b/models/InvestmentPool.ts @@ -1,7 +1,40 @@ import mongoose, { Document, Schema } from "mongoose" export type PoolAssetType = "SHUTTLE" | "KEKE" -export type PoolStatus = "OPEN" | "FUNDED" | "CLOSED" + +/** + * Full lifecycle states for a vehicle investment pool. + * + * Transition matrix (see pool-lifecycle.service.ts for enforcement): + * + * draft → review | cancelled + * review → funding | cancelled + * funding → fully_funded | closing | cancelled + * fully_funded→ closing | cancelled + * closing → active | cancelled + * active → paused | completed | cancelled + * paused → active | cancelled + * completed → archived + * cancelled → (terminal) + * archived → (terminal) + * + * Legacy statuses are mapped on read: OPEN→funding, FUNDED→fully_funded, CLOSED→completed. + */ +export type PoolStatus = + | "draft" + | "review" + | "funding" + | "fully_funded" + | "closing" + | "active" + | "paused" + | "completed" + | "cancelled" + | "archived" + // Legacy aliases kept for backwards-compat reads + | "OPEN" + | "FUNDED" + | "CLOSED" export interface IInvestmentPool extends Document { assetType: PoolAssetType @@ -13,6 +46,12 @@ export interface IInvestmentPool extends Document { investorCount: number createdBy: Schema.Types.ObjectId description?: string + /** Set when funding closes; investments beyond this amount are rejected */ + fundingCapacityNgn: number | null + /** Deadline for funding; after this an underfunded pool auto-cancels */ + fundingDeadline: Date | null + /** Optimistic concurrency version field */ + __v: number createdAt: Date updatedAt: Date } @@ -42,10 +81,32 @@ const InvestmentPoolSchema: Schema = new Schema( }, status: { type: String, - enum: ["OPEN", "FUNDED", "CLOSED"], - default: "OPEN", + enum: [ + "draft", + "review", + "funding", + "fully_funded", + "closing", + "active", + "paused", + "completed", + "cancelled", + "archived", + // Legacy aliases + "OPEN", + "FUNDED", + "CLOSED", + ], + default: "draft", index: true, }, + fundingDeadline: { type: Date }, + closedFundingAt: { type: Date }, + activatedAt: { type: Date }, + completedAt: { type: Date }, + cancelledAt: { type: Date }, + cancellationReason: { type: String, trim: true, maxlength: 400 }, + schemaVersion: { type: Number, default: 2 }, currentRaisedNgn: { type: Number, default: 0, @@ -67,9 +128,18 @@ const InvestmentPoolSchema: Schema = new Schema( trim: true, maxlength: 600, }, + fundingCapacityNgn: { + type: Number, + default: null, + min: 0, + }, + fundingDeadline: { + type: Date, + default: null, + }, }, - { timestamps: true }, + { timestamps: true, optimisticConcurrency: true }, ) -export default (mongoose.models.InvestmentPool || +export default (mongoose.models.InvestmentPool || mongoose.model("InvestmentPool", InvestmentPoolSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/PoolTransitionHistory.ts b/models/PoolTransitionHistory.ts new file mode 100644 index 00000000..e2f0141d --- /dev/null +++ b/models/PoolTransitionHistory.ts @@ -0,0 +1,58 @@ +import mongoose, { Document, Schema } from "mongoose" +import type { PoolStatus } from "./InvestmentPool" + +export interface IPoolTransitionHistory extends Document { + poolId: Schema.Types.ObjectId + fromStatus: PoolStatus + toStatus: PoolStatus + actorId: Schema.Types.ObjectId | null + actorRole: string | null + reason: string | null + correlationId: string + createdAt: Date +} + +const PoolTransitionHistorySchema = new Schema( + { + poolId: { + type: Schema.Types.ObjectId, + ref: "InvestmentPool", + required: true, + index: true, + }, + fromStatus: { + type: String, + required: true, + }, + toStatus: { + type: String, + required: true, + }, + actorId: { + type: Schema.Types.ObjectId, + ref: "User", + default: null, + }, + actorRole: { + type: String, + default: null, + }, + reason: { + type: String, + default: null, + maxlength: 500, + }, + correlationId: { + type: String, + required: true, + default: () => new mongoose.Types.ObjectId().toHexString(), + }, + }, + { timestamps: { createdAt: true, updatedAt: false } }, +) + +export default (mongoose.models.PoolTransitionHistory || + mongoose.model( + "PoolTransitionHistory", + PoolTransitionHistorySchema, + )) as mongoose.Model diff --git a/models/WebhookInboxEvent.ts b/models/WebhookInboxEvent.ts new file mode 100644 index 00000000..cbb69f48 --- /dev/null +++ b/models/WebhookInboxEvent.ts @@ -0,0 +1,122 @@ +import crypto from "crypto" +import mongoose, { Document, Schema } from "mongoose" + +export type WebhookInboxStatus = + | "pending" + | "processing" + | "completed" + | "failed" + | "dead_letter" + +export interface IWebhookInboxEvent extends Document { + providerEventId: string + eventType: string + payloadHash: string + rawPayload: string + status: WebhookInboxStatus + attempts: number + maxAttempts: number + nextRetryAt: Date | null + lastError: string | null + correlationId: string + processedAt: Date | null + replayedAt: Date | null + replayedBy: string | null + replayReason: string | null + createdAt: Date + updatedAt: Date +} + +const WebhookInboxEventSchema = new Schema( + { + providerEventId: { + type: String, + required: true, + trim: true, + index: true, + }, + eventType: { + type: String, + required: true, + trim: true, + index: true, + }, + payloadHash: { + type: String, + required: true, + }, + rawPayload: { + type: String, + required: true, + }, + status: { + type: String, + enum: ["pending", "processing", "completed", "failed", "dead_letter"], + default: "pending", + index: true, + }, + attempts: { + type: Number, + default: 0, + min: 0, + }, + maxAttempts: { + type: Number, + default: 5, + }, + nextRetryAt: { + type: Date, + default: null, + index: true, + }, + lastError: { + type: String, + default: null, + }, + correlationId: { + type: String, + required: true, + default: () => new mongoose.Types.ObjectId().toHexString(), + index: true, + }, + processedAt: { + type: Date, + default: null, + }, + replayedAt: { + type: Date, + default: null, + }, + replayedBy: { + type: String, + default: null, + }, + replayReason: { + type: String, + default: null, + }, + }, + { timestamps: true }, +) + +// Unique per provider event ID to prevent duplicate ingestion +WebhookInboxEventSchema.index({ providerEventId: 1 }, { unique: true }) + +// Hash the payload to detect conflicting deliveries for the same reference +WebhookInboxEventSchema.index({ providerEventId: 1, payloadHash: 1 }) + +export function hashPayload(raw: string): string { + return crypto.createHash("sha256").update(raw).digest("hex") +} + +/** Exponential backoff in seconds: 30s, 2m, 8m, 32m, 2h */ +export function computeNextRetryAt(attempts: number): Date { + const delaySeconds = Math.min(30 * Math.pow(4, attempts - 1), 7200) + return new Date(Date.now() + delaySeconds * 1000) +} + +export default (mongoose.models.WebhookInboxEvent || + mongoose.model( + "WebhookInboxEvent", + WebhookInboxEventSchema, + )) as mongoose.Model