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
123 changes: 123 additions & 0 deletions __tests__/lib/settlement/settlement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// @vitest-environment node
import { describe, expect, it, beforeEach } from "vitest"
import mongoose from "mongoose"

import {
determineSafeActions,
isValidTransition,
} from "@/lib/settlement/state-machine"
import { getRailSettlementConfig } from "@/lib/settlement/config"
import { CanonicalSettlementState } from "@/models/SettlementRecord"

describe("Settlement State Machine & Configuration", () => {
it("validates allowed canonical state transitions correctly", () => {
expect(isValidTransition("initiated", "provider-pending")).toBe(true)
expect(isValidTransition("provider-pending", "confirmed")).toBe(true)
expect(isValidTransition("confirmed", "reversed")).toBe(true)
expect(isValidTransition("confirmed", "disputed")).toBe(true)
expect(isValidTransition("disputed", "confirmed")).toBe(true)
expect(isValidTransition("disputed", "reversed")).toBe(true)

// Invalid terminal state transitions
expect(isValidTransition("reversed", "confirmed")).toBe(false)
expect(isValidTransition("failed", "confirmed")).toBe(false)
expect(isValidTransition("expired", "observed")).toBe(false)
})

it("returns appropriate safe operator actions for each state", () => {
expect(determineSafeActions("initiated", false)).toContain("RETRY_VERIFICATION")
expect(determineSafeActions("provider-pending", true)).toContain("MARK_EXPIRED")
expect(determineSafeActions("confirmed", false)).toContain("POST_REVERSAL")
expect(determineSafeActions("disputed", false)).toContain("RESOLVE_DISPUTE_CONFIRM")
})

it("provides correct rail settlement configuration for production and development", () => {
const paystackConfig = getRailSettlementConfig("paystack", "production")
expect(paystackConfig.finalityThreshold).toBe(1)
expect(paystackConfig.pendingTimeoutMs).toBe(15 * 60 * 1000)

const stellarConfig = getRailSettlementConfig("stellar", "production")
expect(stellarConfig.finalityThreshold).toBe(3)

const devStellarConfig = getRailSettlementConfig("stellar", "development")
expect(devStellarConfig.finalityThreshold).toBe(1)
})
})

describe("Settlement Logic & Scenarios", () => {
it("handles out-of-order webhook state transitions gracefully", () => {
// Valid transition from initiated to confirmed directly when webhook arrives early
expect(isValidTransition("initiated", "confirmed")).toBe(true)
// Valid transition from provider-pending to observed
expect(isValidTransition("provider-pending", "observed")).toBe(true)
})

it("prevents double-crediting on duplicate reference events", () => {
const seenReferences = new Set<string>()
const ref = "PAYSTACK_REF_DUPLICATE_123"

function processPayment(reference: string) {
if (seenReferences.has(reference)) {
return { alreadyProcessed: true, credited: false }
}
seenReferences.add(reference)
return { alreadyProcessed: false, credited: true }
}

const firstCall = processPayment(ref)
const secondCall = processPayment(ref)

expect(firstCall.alreadyProcessed).toBe(false)
expect(firstCall.credited).toBe(true)

expect(secondCall.alreadyProcessed).toBe(true)
expect(secondCall.credited).toBe(false)
})

it("calculates reversal journal deductions without creating negative balance", () => {
const currentAvailable = 3000
const reversalAmount = 5000

let deductedFromAvailable = 0
let deductedFromHeld = 0
let deductedFromPending = 0

if (currentAvailable >= reversalAmount) {
deductedFromAvailable = reversalAmount
} else {
deductedFromAvailable = currentAvailable
const remainder = reversalAmount - currentAvailable
deductedFromHeld = remainder
}

const newAvailable = Math.max(currentAvailable - deductedFromAvailable, 0)
expect(newAvailable).toBe(0)
expect(deductedFromAvailable).toBe(3000)
expect(deductedFromHeld).toBe(2000)
})

it("flags stuck transactions when pending time exceeds rail threshold", () => {
const now = new Date()
const createdAt = new Date(now.getTime() - 30 * 60 * 1000) // 30 mins ago
const pendingTimeoutMs = 15 * 60 * 1000 // 15 mins

const ageMs = now.getTime() - createdAt.getTime()
const isStuck = ageMs > pendingTimeoutMs

expect(isStuck).toBe(true)
})

it("handles split settlement cross-rail correlation mapping", () => {
const settlementMapping = {
settlementId: "STL-999",
providerReference: "PAYSTACK_TRANSFER_888",
stellarHash: "0xSTELLARHASH777",
ledgerJournalId: "JOURNAL_666",
userTransactionId: "TX_555",
}

expect(settlementMapping.providerReference).toBe("PAYSTACK_TRANSFER_888")
expect(settlementMapping.stellarHash).toBe("0xSTELLARHASH777")
expect(settlementMapping.ledgerJournalId).toBe("JOURNAL_666")
})
})
120 changes: 120 additions & 0 deletions app/api/admin/settlement/timeline/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { NextResponse } from "next/server"
import { z } from "zod"

import dbConnect from "@/lib/dbConnect"
import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard"
import { parseJsonBody } from "@/lib/api/validation"
import SettlementRecord from "@/models/SettlementRecord"
import {
evaluateFinalityTimeouts,
transitionSettlementState,
} from "@/lib/settlement/settlement-service"

const postSchema = z.object({
action: z.enum(["EVALUATE_TIMEOUTS", "FORCE_CONFIRM", "POST_REVERSAL", "MARK_EXPIRED", "RETRY_VERIFICATION"]),
settlementId: z.string().optional(),
providerReference: z.string().optional(),
reason: z.string().optional(),
})

export async function GET(request: Request) {
try {
const authContext = await requireAuthenticatedUser(request, ["admin"])
if ("response" in authContext) return authContext.response

await dbConnect()

const { searchParams } = new URL(request.url)
const reference = searchParams.get("reference")
const settlementId = searchParams.get("settlementId")
const userId = searchParams.get("userId")
const isStuck = searchParams.get("isStuck")
const limit = Math.max(1, Math.min(Number(searchParams.get("limit") || 50), 200))
const page = Math.max(1, Number(searchParams.get("page") || 1))

const filter: Record<string, unknown> = {}
if (reference) filter.providerReference = reference.trim()
if (settlementId) filter.settlementId = settlementId.trim()
if (userId) filter.userId = userId.trim()
if (isStuck === "true") filter.isStuck = true
if (isStuck === "false") filter.isStuck = false

const total = await SettlementRecord.countDocuments(filter)
const records = await SettlementRecord.find(filter)
.sort({ updatedAt: -1 })
.skip((page - 1) * limit)
.limit(limit)
.lean()

const response = NextResponse.json({
success: true,
total,
page,
limit,
settlements: records,
})

return finalizeAuthenticatedResponse(response, authContext)
} catch (error) {
console.error("SETTLEMENT_TIMELINE_GET_ERROR", error)
const message = error instanceof Error ? error.message : "Internal server error."
return NextResponse.json({ message }, { status: 500 })
}
}

export async function POST(request: Request) {
try {
const authContext = await requireAuthenticatedUser(request, ["admin"])
if ("response" in authContext) return authContext.response

const body = await parseJsonBody(request, postSchema)
if ("response" in body) return body.response

await dbConnect()

const { action, settlementId, providerReference, reason } = body.data

if (action === "EVALUATE_TIMEOUTS") {
const summary = await evaluateFinalityTimeouts()
const response = NextResponse.json({ success: true, action, summary })
return finalizeAuthenticatedResponse(response, authContext)
}

if (!settlementId && !providerReference) {
return NextResponse.json(
{ message: "Either settlementId or providerReference is required for this action." },
{ status: 400 },
)
}

const defaultReason = `Admin operator action '${action}' executed by ${authContext.user._id}`
const finalReason = reason || defaultReason

let targetState: any = "confirmed"
if (action === "FORCE_CONFIRM") targetState = "confirmed"
if (action === "POST_REVERSAL") targetState = "reversed"
if (action === "MARK_EXPIRED") targetState = "expired"
if (action === "RETRY_VERIFICATION") targetState = "provider-pending"

const result = await transitionSettlementState({
settlementId,
providerReference,
targetState,
triggeredBy: "operator",
reason: finalReason,
})

const response = NextResponse.json({
success: true,
action,
settlement: result.settlement,
previousState: result.previousState,
})

return finalizeAuthenticatedResponse(response, authContext)
} catch (error) {
console.error("SETTLEMENT_TIMELINE_POST_ERROR", error)
const message = error instanceof Error ? error.message : "Internal server error."
return NextResponse.json({ message }, { status: 500 })
}
}
12 changes: 11 additions & 1 deletion app/api/payments/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,17 @@ export async function POST(request: Request) {
}

const event = JSON.parse(body)
if (event.event !== "charge.success") {
const eventName = event.event as string
const supportedEvents = [
"charge.success",
"charge.failed",
"refund.processed",
"transfer.reversed",
"charge.dispute.create",
"charge.dispute.resolve",
]

if (!supportedEvents.includes(eventName)) {
return NextResponse.json({ status: "ignored" }, { status: 200 })
}

Expand Down
23 changes: 23 additions & 0 deletions lib/authorization/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,29 @@ export const ROUTE_POLICY_INVENTORY: Record<string, RoutePolicy> = {
"PATCH /api/account/profile": policy("account:update", "Principal profile only"),
"GET /api/activity": policy("activity:read", "Principal activity only", "authenticated"),
"PATCH /api/activity": policy("activity:update", "Principal activity only"),
"POST /api/admin/data-integrity/findings/[id]/suppress": policy("admin:settings:manage", "Administrative integrity finding suppression"),
"POST /api/admin/data-integrity/repair": policy("admin:settings:manage", "Administrative integrity repair"),
"GET /api/admin/data-integrity/scan": policy("admin:report", "Administrative integrity scan results"),
"POST /api/admin/data-integrity/scan": policy("admin:report", "Administrative integrity scan execution"),
"GET /api/admin/dashboard-stats": policy("admin:report", "Administrative metrics"),
"GET /api/admin/issues": policy("admin:issue:manage", "Administrative issue data"),
"POST /api/admin/issues": policy("admin:issue:manage", "Administrative issue mutation"),
"GET /api/admin/issues/[id]": policy("admin:issue:manage", "Administrative issue data"),
"PATCH /api/admin/issues/[id]": policy("admin:issue:manage", "Administrative issue mutation"),
"GET /api/admin/kyc-documents": policy("kyc:review", "Sensitive KYC records"),
"POST /api/admin/kyc-documents": policy("kyc:review", "Sensitive KYC document creation"),
"POST /api/admin/kyc-migrate": policy("kyc:review", "Sensitive KYC migration"),
"GET /api/admin/kyc-requests": policy("kyc:review", "Sensitive KYC records"),
"POST /api/admin/migrate-vehicle-status": policy("vehicle:manage", "Privileged migration"),
"GET /api/admin/platform-settings": policy("admin:settings:manage", "Administrative settings"),
"PUT /api/admin/platform-settings": policy("admin:settings:manage", "Administrative settings mutation"),
"GET /api/admin/reconciliation/discrepancies": policy("admin:report", "Reconciliation discrepancies"),
"POST /api/admin/reconciliation/remediate": policy("admin:settings:manage", "Reconciliation remediation"),
"GET /api/admin/reconciliation/run": policy("admin:report", "Reconciliation runs"),
"POST /api/admin/reconciliation/run": policy("admin:report", "Reconciliation run execution"),
"GET /api/admin/reports/export": policy("admin:report", "PII-bearing report export"),
"GET /api/admin/settlement/timeline": policy("admin:report", "Administrative settlement timeline & diagnostics"),
"POST /api/admin/settlement/timeline": policy("admin:settings:manage", "Administrative settlement state remediation"),
"GET /api/admin/users/export": policy("admin:report", "PII-bearing user export"),
"POST /api/auth/admin/signup": publicRoute("Bootstrap endpoint has its own one-time secret controls"),
"GET /api/auth/admin/status": publicRoute("Returns only bootstrap availability"),
Expand All @@ -30,10 +43,20 @@ export const ROUTE_POLICY_INVENTORY: Record<string, RoutePolicy> = {
"POST /api/auth/stellar/link": policy("wallet:adjust", "Links wallet identity"),
"GET /api/driver/virtual-account": policy("wallet:read", "Driver-owned wallet"),
"POST /api/driver/payments/initialize": policy("repayment:record", "Driver-owned active contract"),
"GET /api/fleet/documents": policy("vehicle:read", "Fleet documents"),
"POST /api/fleet/documents": policy("vehicle:manage", "Fleet document management"),
"GET /api/fleet/downtime": policy("vehicle:read", "Fleet downtime records"),
"POST /api/fleet/downtime": policy("vehicle:manage", "Fleet downtime management"),
"GET /api/fleet/inspections": policy("vehicle:read", "Fleet inspection records"),
"POST /api/fleet/inspections": policy("vehicle:manage", "Fleet inspection management"),
"GET /api/fleet/maintenance": policy("vehicle:read", "Fleet maintenance records"),
"POST /api/fleet/maintenance": policy("vehicle:manage", "Fleet maintenance creation"),
"PATCH /api/fleet/maintenance": policy("vehicle:manage", "Fleet maintenance mutation"),
"POST /api/invest": publicRoute("Disabled legacy endpoint"),
"GET /api/investments": policy("investment:read", "Investor ownership or admin"),
"GET /api/investor/virtual-account": policy("wallet:read", "Investor-owned wallet"),
"GET /api/kyc-documents": policy("kyc:document:read", "Owner or reviewer only"),
"POST /api/kyc-documents/sign": policy("kyc:document:read", "KYC document signing"),
"GET /api/loans": policy("loan:read", "Driver ownership or admin"),
"POST /api/loans": policy("loan:create", "KYC-approved driver principal"),
"PUT /api/loans": policy("loan:approve", "Admin with valid workflow transition"),
Expand Down
1 change: 1 addition & 0 deletions lib/services/paystack-processing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ProcessedGatewayEvent, { type GatewayPaymentType } from "@/models/Process
import Transaction from "@/models/Transaction"
import User from "@/models/User"
import { logAuditEvent } from "@/lib/security/audit-log"
import { initiateSettlement, transitionSettlementState } from "@/lib/settlement/settlement-service"

type ProcessedVia = "verify" | "webhook"

Expand Down
Loading