From 270e4ed89069f9f952337cb69cbecf21a4e5e1f2 Mon Sep 17 00:00:00 2001 From: Mozez155 Date: Wed, 29 Jul 2026 11:05:19 +0100 Subject: [PATCH] feat(domain): explicit state machines for Loan, Vehicle, and Investment (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces ad-hoc status writes with typed, validated domain transition services that enforce actor permissions, preconditions, atomic side effects, and an immutable audit trail. - Define authoritative transition maps and actor rules for Loan (Pending → Under Review → Approved → Active → Completed), Vehicle (Available ↔ Reserved → Financed, Maintenance, Retired), and Investment (Funding → Active → Completed) - Add LoanTransitionService, VehicleTransitionService, and InvestmentTransitionService as the single entry points for all status mutations; direct field writes are no longer supported - Enforce invariants: loan activation requires full funding and down-payment; maintenance blocked while active loan exists; terminal states (Rejected, Completed, Cancelled, Retired) are locked; optimistic concurrency via version field - Cascade side effects in one MongoDB transaction: loan activation sets vehicle to Financed and investments to Active; loan completion releases vehicle to Available and marks investments Completed - Add StateTransitionHistory model (immutable, indexed by entity, correlationId, and timestamp) for queryable audit trail - Block direct status/fundingStatus writes in PUT /api/vehicles/[id]; all status changes must flow through /transition endpoints - Add integrity check script to detect and repair impossible legacy combinations (--repair flag) - Tests: pure state machine assertions, service integration tests (happy path, forbidden transitions, concurrency, rollback on side-effect failure, migration detection, history completeness) Closes #85 --- .../domain/investment-state-machine.test.ts | 83 ++++ .../investment-transition-service.test.ts | 229 ++++++++++ .../lib/domain/loan-state-machine.test.ts | 183 ++++++++ .../domain/loan-transition-service.test.ts | 423 ++++++++++++++++++ .../domain/state-machine-integrity.test.ts | 299 +++++++++++++ .../lib/domain/vehicle-state-machine.test.ts | 166 +++++++ .../domain/vehicle-transition-service.test.ts | 283 ++++++++++++ app/api/investments/[id]/transition/route.ts | 104 +++++ app/api/loans/[id]/transition/route.ts | 112 +++++ app/api/vehicles/[id]/route.ts | 20 +- app/api/vehicles/[id]/transition/route.ts | 114 +++++ lib/domain/investment-state-machine.ts | 42 ++ lib/domain/investment-transition-service.ts | 174 +++++++ lib/domain/loan-state-machine.ts | 73 +++ lib/domain/loan-transition-service.ts | 270 +++++++++++ lib/domain/transition-error.ts | 32 ++ lib/domain/vehicle-state-machine.ts | 67 +++ lib/domain/vehicle-transition-service.ts | 293 ++++++++++++ models/Investment.ts | 8 +- models/Loan.ts | 24 +- models/StateTransitionHistory.ts | 57 +++ models/Vehicle.ts | 2 + package-lock.json | 76 +++- package.json | 2 + scripts/check-state-machine-integrity.ts | 299 +++++++++++++ 25 files changed, 3392 insertions(+), 43 deletions(-) create mode 100644 __tests__/lib/domain/investment-state-machine.test.ts create mode 100644 __tests__/lib/domain/investment-transition-service.test.ts create mode 100644 __tests__/lib/domain/loan-state-machine.test.ts create mode 100644 __tests__/lib/domain/loan-transition-service.test.ts create mode 100644 __tests__/lib/domain/state-machine-integrity.test.ts create mode 100644 __tests__/lib/domain/vehicle-state-machine.test.ts create mode 100644 __tests__/lib/domain/vehicle-transition-service.test.ts create mode 100644 app/api/investments/[id]/transition/route.ts create mode 100644 app/api/loans/[id]/transition/route.ts create mode 100644 app/api/vehicles/[id]/transition/route.ts create mode 100644 lib/domain/investment-state-machine.ts create mode 100644 lib/domain/investment-transition-service.ts create mode 100644 lib/domain/loan-state-machine.ts create mode 100644 lib/domain/loan-transition-service.ts create mode 100644 lib/domain/transition-error.ts create mode 100644 lib/domain/vehicle-state-machine.ts create mode 100644 lib/domain/vehicle-transition-service.ts create mode 100644 models/StateTransitionHistory.ts create mode 100644 scripts/check-state-machine-integrity.ts diff --git a/__tests__/lib/domain/investment-state-machine.test.ts b/__tests__/lib/domain/investment-state-machine.test.ts new file mode 100644 index 00000000..4eeecad5 --- /dev/null +++ b/__tests__/lib/domain/investment-state-machine.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest" + +import { + isValidInvestmentTransition, + isInvestmentActorAllowed, + isTerminalInvestmentState, + INVESTMENT_VALID_TRANSITIONS, + type InvestmentStatus, +} from "@/lib/domain/investment-state-machine" + +const ALL_STATES: InvestmentStatus[] = ["Funding", "Active", "Completed"] + +describe("Investment state machine", () => { + describe("isValidInvestmentTransition", () => { + it("allows any state from null (initial creation)", () => { + for (const state of ALL_STATES) { + expect(isValidInvestmentTransition(null, state)).toBe(true) + } + }) + + it("allows same-state no-op transitions", () => { + for (const state of ALL_STATES) { + expect(isValidInvestmentTransition(state, state)).toBe(true) + } + }) + + it("walks the lifecycle: Funding -> Active -> Completed", () => { + expect(isValidInvestmentTransition("Funding", "Active")).toBe(true) + expect(isValidInvestmentTransition("Active", "Completed")).toBe(true) + }) + + it("rejects skipping Funding directly to Completed", () => { + expect(isValidInvestmentTransition("Funding", "Completed")).toBe(false) + }) + + it("rejects any transition out of Completed (terminal)", () => { + expect(isValidInvestmentTransition("Completed", "Funding")).toBe(false) + expect(isValidInvestmentTransition("Completed", "Active")).toBe(false) + }) + + it("rejects reverting Active to Funding", () => { + expect(isValidInvestmentTransition("Active", "Funding")).toBe(false) + }) + + it("every listed transition target is a known status", () => { + for (const [from, targets] of Object.entries(INVESTMENT_VALID_TRANSITIONS)) { + expect(ALL_STATES).toContain(from as InvestmentStatus) + for (const target of targets) { + expect(ALL_STATES).toContain(target) + } + } + }) + }) + + describe("isInvestmentActorAllowed", () => { + it("admin and system can activate", () => { + expect(isInvestmentActorAllowed("Active", "admin")).toBe(true) + expect(isInvestmentActorAllowed("Active", "system")).toBe(true) + }) + + it("admin and system can complete", () => { + expect(isInvestmentActorAllowed("Completed", "admin")).toBe(true) + expect(isInvestmentActorAllowed("Completed", "system")).toBe(true) + }) + + it("no actor is allowed to transition into Funding directly", () => { + expect(isInvestmentActorAllowed("Funding", "admin")).toBe(false) + expect(isInvestmentActorAllowed("Funding", "system")).toBe(false) + }) + }) + + describe("isTerminalInvestmentState", () => { + it("marks Completed as terminal", () => { + expect(isTerminalInvestmentState("Completed")).toBe(true) + }) + + it("does not mark non-Completed states as terminal", () => { + expect(isTerminalInvestmentState("Funding")).toBe(false) + expect(isTerminalInvestmentState("Active")).toBe(false) + }) + }) +}) diff --git a/__tests__/lib/domain/investment-transition-service.test.ts b/__tests__/lib/domain/investment-transition-service.test.ts new file mode 100644 index 00000000..d55e4775 --- /dev/null +++ b/__tests__/lib/domain/investment-transition-service.test.ts @@ -0,0 +1,229 @@ +// @vitest-environment node +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest" +import mongoose from "mongoose" + +import Investment from "@/models/Investment" +import Vehicle from "@/models/Vehicle" +import User from "@/models/User" +import StateTransitionHistory from "@/models/StateTransitionHistory" +import { + transitionInvestment, + DomainTransitionError, + DomainConcurrencyError, +} from "@/lib/domain/investment-transition-service" + +const TEST_URI = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chainmove-test" + +beforeAll(async () => { + try { + await mongoose.connect(TEST_URI, { serverSelectionTimeoutMS: 3000 }) + } catch { + console.warn("MongoDB not available — skipping investment service tests") + } +}, 10000) + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close() + } +}) + +function skip() { + return mongoose.connection.readyState !== 1 +} + +async function makeVehicle() { + return Vehicle.create({ + name: "Invest Test Van", + type: "van", + year: 2023, + price: 400000, + roi: 14, + status: "Available", + fundingStatus: "Open", + specifications: { + engine: "2.0L", + fuelType: "diesel", + mileage: "28km/l", + transmission: "manual", + color: "grey", + vin: `VIN-I-${Date.now()}-${Math.random().toString(36).slice(2)}`, + }, + }) +} + +async function makeInvestor() { + return User.create({ + name: "Invest Test Investor", + email: `inv-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`, + role: "investor", + }) +} + +async function makeAdmin() { + return User.create({ + name: "Invest Test Admin", + email: `admin-i-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`, + role: "admin", + }) +} + +async function makeInvestment(vehicleId: unknown, investorId: unknown, overrides: Record = {}) { + return Investment.create({ + investorId, + vehicleId, + amount: 100000, + monthlyReturn: 1167, + status: "Funding", + version: 0, + ...overrides, + }) +} + +describe("Investment transition service", () => { + let adminUser: any + let investor: any + let vehicle: any + + beforeEach(async () => { + if (skip()) return + adminUser = await makeAdmin() + investor = await makeInvestor() + vehicle = await makeVehicle() + }) + + // ── Happy path ────────────────────────────────────────────────────────────── + + it("activates a Funding investment", async () => { + if (skip()) return + const inv = await makeInvestment(vehicle._id, investor._id) + const result = await transitionInvestment({ + investmentId: inv._id.toString(), + command: "activate", + actor: { type: "system" }, + reason: "Loan activated", + }) + expect(result.previousStatus).toBe("Funding") + expect(result.nextStatus).toBe("Active") + }) + + it("completes an Active investment", async () => { + if (skip()) return + const inv = await makeInvestment(vehicle._id, investor._id, { status: "Active" }) + const result = await transitionInvestment({ + investmentId: inv._id.toString(), + command: "complete", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Loan repaid in full", + }) + expect(result.nextStatus).toBe("Completed") + }) + + it("records transition history", async () => { + if (skip()) return + const inv = await makeInvestment(vehicle._id, investor._id) + await transitionInvestment({ + investmentId: inv._id.toString(), + command: "activate", + actor: { type: "system" }, + reason: "History check", + }) + const history = await StateTransitionHistory.findOne({ + entityType: "investment", + entityId: inv._id, + }) + expect(history).not.toBeNull() + expect(history!.fromState).toBe("Funding") + expect(history!.toState).toBe("Active") + }) + + it("is a no-op when already in target state", async () => { + if (skip()) return + const inv = await makeInvestment(vehicle._id, investor._id, { status: "Active" }) + const result = await transitionInvestment({ + investmentId: inv._id.toString(), + command: "activate", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Already active", + }) + expect(result.previousStatus).toBe("Active") + expect(result.nextStatus).toBe("Active") + }) + + // ── Forbidden transitions ────────────────────────────────────────────────── + + it("rejects completing from Funding (must activate first)", async () => { + if (skip()) return + const inv = await makeInvestment(vehicle._id, investor._id) + await expect( + transitionInvestment({ + investmentId: inv._id.toString(), + command: "complete", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Skipping Active", + }), + ).rejects.toMatchObject({ code: "INVALID_TRANSITION" }) + }) + + it("rejects any transition from Completed (terminal)", async () => { + if (skip()) return + const inv = await makeInvestment(vehicle._id, investor._id, { status: "Completed" }) + await expect( + transitionInvestment({ + investmentId: inv._id.toString(), + command: "activate", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Reactivating completed investment", + }), + ).rejects.toMatchObject({ code: "INVALID_TRANSITION" }) + }) + + it("rejects stale version (concurrent transition)", async () => { + if (skip()) return + const inv = await makeInvestment(vehicle._id, investor._id) + await transitionInvestment({ + investmentId: inv._id.toString(), + command: "activate", + actor: { type: "system" }, + reason: "First activation", + }) + await expect( + transitionInvestment({ + investmentId: inv._id.toString(), + command: "complete", + actor: { type: "system" }, + reason: "Concurrent complete", + expectedVersion: 0, + }), + ).rejects.toBeInstanceOf(DomainConcurrencyError) + }) + + it("requires a non-empty reason", async () => { + if (skip()) return + const inv = await makeInvestment(vehicle._id, investor._id) + await expect( + transitionInvestment({ + investmentId: inv._id.toString(), + command: "activate", + actor: { type: "system" }, + reason: " ", + }), + ).rejects.toMatchObject({ code: "REASON_REQUIRED" }) + }) + + it("transition history is queryable by correlationId", async () => { + if (skip()) return + const inv = await makeInvestment(vehicle._id, investor._id) + const correlationId = `corr-${Date.now()}` + await transitionInvestment({ + investmentId: inv._id.toString(), + command: "activate", + actor: { type: "system" }, + reason: "Correlation test", + correlationId, + }) + const history = await StateTransitionHistory.findOne({ correlationId }) + expect(history).not.toBeNull() + expect(history!.entityType).toBe("investment") + }) +}) diff --git a/__tests__/lib/domain/loan-state-machine.test.ts b/__tests__/lib/domain/loan-state-machine.test.ts new file mode 100644 index 00000000..506a09a8 --- /dev/null +++ b/__tests__/lib/domain/loan-state-machine.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest" + +import { + isValidLoanTransition, + isLoanActorAllowed, + isTerminalLoanState, + isRepayableLoanState, + LOAN_VALID_TRANSITIONS, + TERMINAL_LOAN_STATES, + PRE_ACTIVE_LOAN_STATES, + type LoanStatus, +} from "@/lib/domain/loan-state-machine" + +const ALL_STATES: LoanStatus[] = [ + "Pending", + "Under Review", + "Approved", + "Rejected", + "Active", + "Completed", + "Cancelled", +] + +describe("Loan state machine", () => { + describe("isValidLoanTransition", () => { + it("allows any state from null (initial creation)", () => { + for (const state of ALL_STATES) { + expect(isValidLoanTransition(null, state)).toBe(true) + } + }) + + it("allows same-state no-op transitions", () => { + for (const state of ALL_STATES) { + expect(isValidLoanTransition(state, state)).toBe(true) + } + }) + + it("walks the happy path lifecycle", () => { + expect(isValidLoanTransition("Pending", "Under Review")).toBe(true) + expect(isValidLoanTransition("Under Review", "Approved")).toBe(true) + expect(isValidLoanTransition("Approved", "Active")).toBe(true) + expect(isValidLoanTransition("Active", "Completed")).toBe(true) + }) + + it("allows rejection from any pre-active state", () => { + expect(isValidLoanTransition("Pending", "Rejected")).toBe(true) + expect(isValidLoanTransition("Under Review", "Rejected")).toBe(true) + expect(isValidLoanTransition("Approved", "Rejected")).toBe(true) + }) + + it("allows cancellation from any pre-active state", () => { + expect(isValidLoanTransition("Pending", "Cancelled")).toBe(true) + expect(isValidLoanTransition("Under Review", "Cancelled")).toBe(true) + expect(isValidLoanTransition("Approved", "Cancelled")).toBe(true) + }) + + it("rejects skipping Under Review", () => { + expect(isValidLoanTransition("Pending", "Approved")).toBe(false) + expect(isValidLoanTransition("Pending", "Active")).toBe(false) + }) + + it("rejects skipping Approved", () => { + expect(isValidLoanTransition("Under Review", "Active")).toBe(false) + }) + + it("rejects any transition out of terminal states", () => { + for (const terminal of TERMINAL_LOAN_STATES) { + for (const state of ALL_STATES) { + if (state === terminal) continue + expect(isValidLoanTransition(terminal, state)).toBe(false) + } + } + }) + + it("rejects cancelling an active loan", () => { + expect(isValidLoanTransition("Active", "Cancelled")).toBe(false) + }) + + it("rejects re-activating a completed loan", () => { + expect(isValidLoanTransition("Completed", "Active")).toBe(false) + }) + + it("every listed transition target is a known status", () => { + for (const [from, targets] of Object.entries(LOAN_VALID_TRANSITIONS)) { + expect(ALL_STATES).toContain(from as LoanStatus) + for (const target of targets) { + expect(ALL_STATES).toContain(target) + } + } + }) + }) + + describe("isLoanActorAllowed", () => { + it("only admin can start review", () => { + expect(isLoanActorAllowed("Under Review", "Pending", "admin")).toBe(true) + expect(isLoanActorAllowed("Under Review", "Pending", "driver")).toBe(false) + expect(isLoanActorAllowed("Under Review", "Pending", "system")).toBe(false) + }) + + it("only admin can approve", () => { + expect(isLoanActorAllowed("Approved", "Under Review", "admin")).toBe(true) + expect(isLoanActorAllowed("Approved", "Under Review", "driver")).toBe(false) + expect(isLoanActorAllowed("Approved", "Under Review", "system")).toBe(false) + }) + + it("only admin can reject", () => { + expect(isLoanActorAllowed("Rejected", "Pending", "admin")).toBe(true) + expect(isLoanActorAllowed("Rejected", "Pending", "driver")).toBe(false) + expect(isLoanActorAllowed("Rejected", "Pending", "system")).toBe(false) + }) + + it("admin and system can activate", () => { + expect(isLoanActorAllowed("Active", "Approved", "admin")).toBe(true) + expect(isLoanActorAllowed("Active", "Approved", "system")).toBe(true) + expect(isLoanActorAllowed("Active", "Approved", "driver")).toBe(false) + }) + + it("admin and system can complete", () => { + expect(isLoanActorAllowed("Completed", "Active", "admin")).toBe(true) + expect(isLoanActorAllowed("Completed", "Active", "system")).toBe(true) + expect(isLoanActorAllowed("Completed", "Active", "driver")).toBe(false) + }) + + it("driver can cancel from any pre-active state", () => { + for (const state of PRE_ACTIVE_LOAN_STATES) { + expect(isLoanActorAllowed("Cancelled", state, "driver")).toBe(true) + } + }) + + it("driver cannot cancel from Active (not a pre-active state)", () => { + // Active is not in PRE_ACTIVE_LOAN_STATES, but the transition itself is also invalid + expect(isLoanActorAllowed("Cancelled", "Active", "driver")).toBe(false) + }) + + it("admin can cancel from any pre-active state", () => { + for (const state of PRE_ACTIVE_LOAN_STATES) { + expect(isLoanActorAllowed("Cancelled", state, "admin")).toBe(true) + } + }) + + it("driver cannot cancel from null (initial)", () => { + expect(isLoanActorAllowed("Cancelled", null, "driver")).toBe(false) + }) + }) + + describe("isTerminalLoanState", () => { + it("marks Rejected, Completed, and Cancelled as terminal", () => { + expect(isTerminalLoanState("Rejected")).toBe(true) + expect(isTerminalLoanState("Completed")).toBe(true) + expect(isTerminalLoanState("Cancelled")).toBe(true) + }) + + it("does not mark non-terminal states as terminal", () => { + expect(isTerminalLoanState("Pending")).toBe(false) + expect(isTerminalLoanState("Under Review")).toBe(false) + expect(isTerminalLoanState("Approved")).toBe(false) + expect(isTerminalLoanState("Active")).toBe(false) + }) + }) + + describe("isRepayableLoanState", () => { + it("only Active is repayable", () => { + expect(isRepayableLoanState("Active")).toBe(true) + }) + + it("no other states are repayable", () => { + const nonRepayable: Array = [ + "Pending", + "Under Review", + "Approved", + "Rejected", + "Completed", + "Cancelled", + null, + undefined, + ] + for (const state of nonRepayable) { + expect(isRepayableLoanState(state)).toBe(false) + } + }) + }) +}) diff --git a/__tests__/lib/domain/loan-transition-service.test.ts b/__tests__/lib/domain/loan-transition-service.test.ts new file mode 100644 index 00000000..536aa532 --- /dev/null +++ b/__tests__/lib/domain/loan-transition-service.test.ts @@ -0,0 +1,423 @@ +// @vitest-environment node +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, afterEach } from "vitest" +import mongoose from "mongoose" + +import Loan from "@/models/Loan" +import Vehicle from "@/models/Vehicle" +import Investment from "@/models/Investment" +import User from "@/models/User" +import StateTransitionHistory from "@/models/StateTransitionHistory" +import { + transitionLoan, + DomainTransitionError, + DomainConcurrencyError, +} from "@/lib/domain/loan-transition-service" + +const TEST_URI = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chainmove-test" + +beforeAll(async () => { + try { + await mongoose.connect(TEST_URI, { serverSelectionTimeoutMS: 3000 }) + } catch { + console.warn("MongoDB not available — skipping loan service tests") + } +}, 10000) + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close() + } +}) + +function skip() { + return mongoose.connection.readyState !== 1 +} + +async function makeVehicle(overrides: Record = {}) { + return Vehicle.create({ + name: "Test Van", + type: "van", + year: 2022, + price: 500000, + roi: 15, + status: "Available", + fundingStatus: "Open", + specifications: { engine: "3.0L", fuelType: "diesel", mileage: "30km/l", transmission: "manual", color: "white", vin: `VIN-${Date.now()}-${Math.random().toString(36).slice(2)}` }, + ...overrides, + }) +} + +async function makeUser(role = "admin") { + return User.create({ + name: `Test ${role}`, + email: `${role}-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`, + role, + }) +} + +async function makeLoan(vehicleId: unknown, driverId: unknown, overrides: Record = {}) { + return Loan.create({ + driverId, + vehicleId, + requestedAmount: 500000, + totalFunded: 0, + fundingProgress: 0, + loanTerm: 12, + monthlyPayment: 45000, + weeklyPayment: 11000, + interestRate: 8, + status: "Pending", + version: 0, + ...overrides, + }) +} + +describe("Loan transition service", () => { + let adminUser: any + let driverUser: any + let vehicle: any + + beforeEach(async () => { + if (skip()) return + adminUser = await makeUser("admin") + driverUser = await makeUser("driver") + vehicle = await makeVehicle() + }) + + // ── Happy path ────────────────────────────────────────────────────────────── + + it("transitions Pending -> Under Review", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id) + const result = await transitionLoan({ + loanId: loan._id.toString(), + command: "startReview", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Starting review", + }) + expect(result.previousStatus).toBe("Pending") + expect(result.nextStatus).toBe("Under Review") + expect(result.loan.status).toBe("Under Review") + }) + + it("transitions Under Review -> Approved and records history", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { status: "Under Review" }) + await transitionLoan({ + loanId: loan._id.toString(), + command: "approve", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Approved after review", + }) + const history = await StateTransitionHistory.findOne({ + entityType: "loan", + entityId: loan._id, + toState: "Approved", + }) + expect(history).not.toBeNull() + expect(history!.fromState).toBe("Under Review") + expect(history!.actorType).toBe("admin") + }) + + it("activates a fully funded loan with down payment", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { + status: "Approved", + totalFunded: 500000, + downPaymentMade: true, + }) + const result = await transitionLoan({ + loanId: loan._id.toString(), + command: "activate", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Loan fully funded and down payment received", + }) + expect(result.nextStatus).toBe("Active") + + const updatedVehicle = await Vehicle.findById(vehicle._id) + expect(updatedVehicle!.status).toBe("Financed") + expect(updatedVehicle!.fundingStatus).toBe("Active") + }) + + it("completes an active loan and releases vehicle", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { + status: "Active", + totalFunded: 500000, + downPaymentMade: true, + }) + await Vehicle.findByIdAndUpdate(vehicle._id, { status: "Financed", driverId: driverUser._id, fundingStatus: "Active" }) + + const result = await transitionLoan({ + loanId: loan._id.toString(), + command: "complete", + actor: { type: "system" }, + reason: "All repayments received", + }) + expect(result.nextStatus).toBe("Completed") + + const updatedVehicle = await Vehicle.findById(vehicle._id) + expect(updatedVehicle!.status).toBe("Available") + expect(updatedVehicle!.fundingStatus).toBe("Open") + }) + + it("driver can cancel a pending loan", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id) + const result = await transitionLoan({ + loanId: loan._id.toString(), + command: "cancel", + actor: { type: "driver", id: driverUser._id.toString() }, + reason: "Changed my mind", + }) + expect(result.nextStatus).toBe("Cancelled") + + const updatedVehicle = await Vehicle.findById(vehicle._id) + expect(updatedVehicle!.status).toBe("Available") + }) + + // ── Forbidden transitions ────────────────────────────────────────────────── + + it("rejects activating without full funding", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { + status: "Approved", + totalFunded: 100000, + downPaymentMade: true, + }) + await expect( + transitionLoan({ + loanId: loan._id.toString(), + command: "activate", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Trying to activate underfunded loan", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }) + }) + + it("rejects activating without down payment", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { + status: "Approved", + totalFunded: 500000, + downPaymentMade: false, + }) + await expect( + transitionLoan({ + loanId: loan._id.toString(), + command: "activate", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "No down payment yet", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }) + }) + + it("rejects driver approving a loan (forbidden actor)", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { status: "Under Review" }) + await expect( + transitionLoan({ + loanId: loan._id.toString(), + command: "approve", + actor: { type: "driver", id: driverUser._id.toString() }, + reason: "Trying to self-approve", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN_ACTOR" }) + }) + + it("rejects skipping Under Review (Pending -> Approved)", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id) + await expect( + transitionLoan({ + loanId: loan._id.toString(), + command: "approve", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Skipping review", + }), + ).rejects.toMatchObject({ code: "INVALID_TRANSITION" }) + }) + + it("rejects re-transitioning from a terminal state (Rejected)", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { status: "Rejected" }) + await expect( + transitionLoan({ + loanId: loan._id.toString(), + command: "startReview", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Trying to reopen", + }), + ).rejects.toMatchObject({ code: "INVALID_TRANSITION" }) + }) + + it("returns 409 on stale version (concurrent transition)", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id) + // First transition succeeds and bumps version to 1 + await transitionLoan({ + loanId: loan._id.toString(), + command: "startReview", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "First transition", + }) + // Second transition with stale version 0 should fail + await expect( + transitionLoan({ + loanId: loan._id.toString(), + command: "reject", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Concurrent reject", + expectedVersion: 0, + }), + ).rejects.toBeInstanceOf(DomainConcurrencyError) + }) + + it("requires a non-empty reason", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id) + await expect( + transitionLoan({ + loanId: loan._id.toString(), + command: "startReview", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: " ", + }), + ).rejects.toMatchObject({ code: "REASON_REQUIRED" }) + }) + + it("activates investments when loan activates", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { + status: "Approved", + totalFunded: 500000, + downPaymentMade: true, + }) + const inv = await Investment.create({ + investorId: adminUser._id, + vehicleId: vehicle._id, + loanId: loan._id, + amount: 500000, + monthlyReturn: 6250, + status: "Funding", + version: 0, + }) + + await transitionLoan({ + loanId: loan._id.toString(), + command: "activate", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Activation", + }) + + const updatedInv = await Investment.findById(inv._id) + expect(updatedInv!.status).toBe("Active") + }) + + it("completes investments when loan completes", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { + status: "Active", + totalFunded: 500000, + downPaymentMade: true, + }) + const inv = await Investment.create({ + investorId: adminUser._id, + vehicleId: vehicle._id, + loanId: loan._id, + amount: 500000, + monthlyReturn: 6250, + status: "Active", + version: 0, + }) + + await transitionLoan({ + loanId: loan._id.toString(), + command: "complete", + actor: { type: "system" }, + reason: "Fully repaid", + }) + + const updatedInv = await Investment.findById(inv._id) + expect(updatedInv!.status).toBe("Completed") + }) + + it("transition history is immutable (no update method available)", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id) + await transitionLoan({ + loanId: loan._id.toString(), + command: "startReview", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Review started", + }) + const count = await StateTransitionHistory.countDocuments({ + entityType: "loan", + entityId: loan._id, + }) + expect(count).toBe(1) + // History records must not be replaced or deleted; only additional records are appended + }) + + it("rolls back loan status when a side effect throws", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id, { + status: "Approved", + totalFunded: 500000, + downPaymentMade: true, + }) + + // Force the Investment bulk update (side effect) to throw + const original = Investment.updateMany.bind(Investment) + const spy = vi.spyOn(Investment, "updateMany").mockImplementationOnce(() => { + throw new Error("Simulated investment update failure") + }) + + await expect( + transitionLoan({ + loanId: loan._id.toString(), + command: "activate", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Activate with failing side effect", + }), + ).rejects.toThrow("Simulated investment update failure") + + // Loan must still be Approved — the transaction was aborted + const unchanged = await Loan.findById(loan._id) + expect(unchanged!.status).toBe("Approved") + + spy.mockRestore() + }) + + it("transition history records all steps in a multi-hop lifecycle", async () => { + if (skip()) return + const loan = await makeLoan(vehicle._id, driverUser._id) + const cmds: Array<{ command: "startReview" | "approve" | "reject"; reason: string }> = [ + { command: "startReview", reason: "Starting" }, + { command: "approve", reason: "Looks good" }, + { command: "reject", reason: "Changed mind" }, + ] + + for (const { command, reason } of cmds) { + try { + await transitionLoan({ + loanId: loan._id.toString(), + command, + actor: { type: "admin", id: adminUser._id.toString() }, + reason, + }) + } catch { + // reject from Approved is valid but let's just collect what succeeded + } + } + + const history = await StateTransitionHistory.find({ + entityType: "loan", + entityId: loan._id, + }).sort({ timestamp: 1 }) + + expect(history.length).toBeGreaterThanOrEqual(2) + expect(history[0].fromState).toBe("Pending") + expect(history[0].toState).toBe("Under Review") + }) +}) diff --git a/__tests__/lib/domain/state-machine-integrity.test.ts b/__tests__/lib/domain/state-machine-integrity.test.ts new file mode 100644 index 00000000..b4e2a8e5 --- /dev/null +++ b/__tests__/lib/domain/state-machine-integrity.test.ts @@ -0,0 +1,299 @@ +// @vitest-environment node +/** + * Tests for the integrity scanner that detects impossible state combinations + * left by pre-state-machine code or interrupted transactions. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest" +import mongoose from "mongoose" + +import Loan from "@/models/Loan" +import Vehicle from "@/models/Vehicle" +import Investment from "@/models/Investment" +import User from "@/models/User" + +const TEST_URI = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chainmove-test" + +beforeAll(async () => { + try { + await mongoose.connect(TEST_URI, { serverSelectionTimeoutMS: 3000 }) + } catch { + console.warn("MongoDB not available — skipping integrity tests") + } +}, 10000) + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close() + } +}) + +function skip() { + return mongoose.connection.readyState !== 1 +} + +async function makeVehicle(overrides: Record = {}) { + return Vehicle.create({ + name: "Integrity Test Van", + type: "van", + year: 2022, + price: 500000, + roi: 12, + status: "Available", + fundingStatus: "Open", + specifications: { + engine: "2.0L", + fuelType: "diesel", + mileage: "28km/l", + transmission: "manual", + color: "black", + vin: `VIN-INT-${Date.now()}-${Math.random().toString(36).slice(2)}`, + }, + ...overrides, + }) +} + +async function makeUser(role = "admin") { + return User.create({ + name: `Integrity ${role}`, + email: `int-${role}-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`, + role, + }) +} + +// Inline the detection logic so tests run without spawning a subprocess +async function detectFinancedVehiclesWithNoActiveLoan(): Promise { + const financedVehicles = await Vehicle.find({ status: "Financed" }).select("_id").lean() + const findings: mongoose.Types.ObjectId[] = [] + for (const v of financedVehicles) { + const activeLoan = await Loan.findOne({ vehicleId: v._id, status: "Active" }).select("_id").lean() + if (!activeLoan) findings.push(v._id) + } + return findings +} + +async function detectReservedVehiclesWithNoInProgressLoan(): Promise { + const reservedVehicles = await Vehicle.find({ status: "Reserved" }).select("_id").lean() + const findings: mongoose.Types.ObjectId[] = [] + for (const v of reservedVehicles) { + const inProgressLoan = await Loan.findOne({ + vehicleId: v._id, + status: { $in: ["Pending", "Under Review", "Approved"] }, + }) + .select("_id") + .lean() + if (!inProgressLoan) findings.push(v._id) + } + return findings +} + +async function detectActiveLoansWithInsufficientFunding(): Promise { + const activeLoans = await Loan.find({ status: "Active" }).select("_id requestedAmount totalFunded").lean() + return activeLoans + .filter((l) => Number(l.totalFunded) < Number(l.requestedAmount)) + .map((l) => l._id) +} + +async function detectActiveInvestmentsOnTerminatedLoans(): Promise { + const activeInvestments = await Investment.find({ status: "Active", loanId: { $exists: true } }) + .select("_id loanId") + .lean() + const findings: mongoose.Types.ObjectId[] = [] + for (const inv of activeInvestments) { + const loan = await Loan.findById(inv.loanId).select("status").lean() + if (loan && ["Completed", "Rejected", "Cancelled"].includes(loan.status)) { + findings.push(inv._id) + } + } + return findings +} + +describe("State machine integrity scanner", () => { + let driver: any + + beforeEach(async () => { + if (skip()) return + driver = await makeUser("driver") + }) + + describe("Financed vehicle with no active loan", () => { + it("detects a Financed vehicle that has no matching Active loan", async () => { + if (skip()) return + + const vehicle = await makeVehicle({ status: "Financed" }) + + const findings = await detectFinancedVehiclesWithNoActiveLoan() + + const flagged = findings.some((id) => id.toString() === vehicle._id.toString()) + expect(flagged).toBe(true) + + await Vehicle.findByIdAndDelete(vehicle._id) + }) + + it("does not flag a Financed vehicle that has an Active loan", async () => { + if (skip()) return + + const vehicle = await makeVehicle({ status: "Financed" }) + await Loan.create({ + driverId: driver._id, + vehicleId: vehicle._id, + requestedAmount: 500000, + totalFunded: 500000, + fundingProgress: 100, + loanTerm: 12, + monthlyPayment: 45000, + interestRate: 8, + status: "Active", + downPaymentMade: true, + version: 1, + }) + + const findings = await detectFinancedVehiclesWithNoActiveLoan() + const flagged = findings.some((id) => id.toString() === vehicle._id.toString()) + expect(flagged).toBe(false) + + await Vehicle.findByIdAndDelete(vehicle._id) + }) + }) + + describe("Reserved vehicle with no in-progress loan", () => { + it("detects a Reserved vehicle that has no matching in-progress loan", async () => { + if (skip()) return + + const vehicle = await makeVehicle({ status: "Reserved" }) + const findings = await detectReservedVehiclesWithNoInProgressLoan() + + const flagged = findings.some((id) => id.toString() === vehicle._id.toString()) + expect(flagged).toBe(true) + + await Vehicle.findByIdAndDelete(vehicle._id) + }) + + it("does not flag a Reserved vehicle with a Pending loan", async () => { + if (skip()) return + + const vehicle = await makeVehicle({ status: "Reserved" }) + await Loan.create({ + driverId: driver._id, + vehicleId: vehicle._id, + requestedAmount: 500000, + totalFunded: 0, + fundingProgress: 0, + loanTerm: 12, + monthlyPayment: 45000, + interestRate: 8, + status: "Pending", + version: 0, + }) + + const findings = await detectReservedVehiclesWithNoInProgressLoan() + const flagged = findings.some((id) => id.toString() === vehicle._id.toString()) + expect(flagged).toBe(false) + + await Vehicle.findByIdAndDelete(vehicle._id) + }) + }) + + describe("Active loan with insufficient funding", () => { + it("detects an Active loan whose totalFunded is below requestedAmount", async () => { + if (skip()) return + + const vehicle = await makeVehicle({ status: "Financed" }) + const loan = await Loan.create({ + driverId: driver._id, + vehicleId: vehicle._id, + requestedAmount: 500000, + totalFunded: 100000, + fundingProgress: 20, + loanTerm: 12, + monthlyPayment: 45000, + interestRate: 8, + status: "Active", + downPaymentMade: true, + version: 1, + }) + + const findings = await detectActiveLoansWithInsufficientFunding() + const flagged = findings.some((id) => id.toString() === loan._id.toString()) + expect(flagged).toBe(true) + + await Loan.findByIdAndDelete(loan._id) + await Vehicle.findByIdAndDelete(vehicle._id) + }) + }) + + describe("Active investment on a terminated loan", () => { + it("detects an Active investment whose loan is Completed", async () => { + if (skip()) return + + const admin = await makeUser("admin") + const vehicle = await makeVehicle() + const loan = await Loan.create({ + driverId: driver._id, + vehicleId: vehicle._id, + requestedAmount: 500000, + totalFunded: 500000, + fundingProgress: 100, + loanTerm: 12, + monthlyPayment: 45000, + interestRate: 8, + status: "Completed", + downPaymentMade: true, + version: 2, + }) + const inv = await Investment.create({ + investorId: admin._id, + vehicleId: vehicle._id, + loanId: loan._id, + amount: 500000, + monthlyReturn: 6250, + status: "Active", + version: 1, + }) + + const findings = await detectActiveInvestmentsOnTerminatedLoans() + const flagged = findings.some((id) => id.toString() === inv._id.toString()) + expect(flagged).toBe(true) + + await Investment.findByIdAndDelete(inv._id) + await Loan.findByIdAndDelete(loan._id) + await Vehicle.findByIdAndDelete(vehicle._id) + }) + + it("does not flag an Active investment on an Active loan", async () => { + if (skip()) return + + const admin = await makeUser("admin") + const vehicle = await makeVehicle({ status: "Financed" }) + const loan = await Loan.create({ + driverId: driver._id, + vehicleId: vehicle._id, + requestedAmount: 500000, + totalFunded: 500000, + fundingProgress: 100, + loanTerm: 12, + monthlyPayment: 45000, + interestRate: 8, + status: "Active", + downPaymentMade: true, + version: 1, + }) + const inv = await Investment.create({ + investorId: admin._id, + vehicleId: vehicle._id, + loanId: loan._id, + amount: 500000, + monthlyReturn: 6250, + status: "Active", + version: 1, + }) + + const findings = await detectActiveInvestmentsOnTerminatedLoans() + const flagged = findings.some((id) => id.toString() === inv._id.toString()) + expect(flagged).toBe(false) + + await Investment.findByIdAndDelete(inv._id) + await Loan.findByIdAndDelete(loan._id) + await Vehicle.findByIdAndDelete(vehicle._id) + }) + }) +}) diff --git a/__tests__/lib/domain/vehicle-state-machine.test.ts b/__tests__/lib/domain/vehicle-state-machine.test.ts new file mode 100644 index 00000000..23c21771 --- /dev/null +++ b/__tests__/lib/domain/vehicle-state-machine.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest" + +import { + isValidVehicleTransition, + isVehicleActorAllowed, + isTerminalVehicleStatus, + isValidVehicleFundingTransition, + VEHICLE_VALID_TRANSITIONS, + VEHICLE_FUNDING_TRANSITIONS, + type VehicleStatus, + type VehicleFundingStatus, +} from "@/lib/domain/vehicle-state-machine" + +const ALL_STATUSES: VehicleStatus[] = [ + "Available", + "Reserved", + "Financed", + "Maintenance", + "Retired", +] + +const ALL_FUNDING_STATUSES: VehicleFundingStatus[] = ["Open", "Funded", "Active"] + +describe("Vehicle state machine", () => { + describe("isValidVehicleTransition", () => { + it("allows any status from null (initial)", () => { + for (const status of ALL_STATUSES) { + expect(isValidVehicleTransition(null, status)).toBe(true) + } + }) + + it("allows same-status no-op transitions", () => { + for (const status of ALL_STATUSES) { + expect(isValidVehicleTransition(status, status)).toBe(true) + } + }) + + it("walks the reservation → financing lifecycle", () => { + expect(isValidVehicleTransition("Available", "Reserved")).toBe(true) + expect(isValidVehicleTransition("Reserved", "Financed")).toBe(true) + }) + + it("allows releasing a reservation back to Available", () => { + expect(isValidVehicleTransition("Reserved", "Available")).toBe(true) + }) + + it("allows a financed vehicle to return to Available on loan completion", () => { + expect(isValidVehicleTransition("Financed", "Available")).toBe(true) + }) + + it("allows entering maintenance from Available, Reserved, and Financed", () => { + expect(isValidVehicleTransition("Available", "Maintenance")).toBe(true) + expect(isValidVehicleTransition("Reserved", "Maintenance")).toBe(true) + expect(isValidVehicleTransition("Financed", "Maintenance")).toBe(true) + }) + + it("allows exiting maintenance back to Available", () => { + expect(isValidVehicleTransition("Maintenance", "Available")).toBe(true) + }) + + it("allows retirement from Available, Financed, and Maintenance", () => { + expect(isValidVehicleTransition("Available", "Retired")).toBe(true) + expect(isValidVehicleTransition("Financed", "Retired")).toBe(true) + expect(isValidVehicleTransition("Maintenance", "Retired")).toBe(true) + }) + + it("rejects any transition out of Retired (terminal)", () => { + for (const status of ALL_STATUSES) { + if (status === "Retired") continue + expect(isValidVehicleTransition("Retired", status)).toBe(false) + } + }) + + it("rejects Maintenance -> Reserved (must exit to Available first)", () => { + expect(isValidVehicleTransition("Maintenance", "Reserved")).toBe(false) + }) + + it("rejects skipping reservation (Available -> Financed)", () => { + expect(isValidVehicleTransition("Available", "Financed")).toBe(false) + }) + + it("every listed transition target is a known status", () => { + for (const [from, targets] of Object.entries(VEHICLE_VALID_TRANSITIONS)) { + expect(ALL_STATUSES).toContain(from as VehicleStatus) + for (const target of targets) { + expect(ALL_STATUSES).toContain(target) + } + } + }) + }) + + describe("isVehicleActorAllowed", () => { + it("admin and system can reserve a vehicle", () => { + expect(isVehicleActorAllowed("Reserved", "admin")).toBe(true) + expect(isVehicleActorAllowed("Reserved", "system")).toBe(true) + }) + + it("admin and system can finalize financing", () => { + expect(isVehicleActorAllowed("Financed", "admin")).toBe(true) + expect(isVehicleActorAllowed("Financed", "system")).toBe(true) + }) + + it("only admin can enter maintenance", () => { + expect(isVehicleActorAllowed("Maintenance", "admin")).toBe(true) + expect(isVehicleActorAllowed("Maintenance", "system")).toBe(false) + }) + + it("only admin can retire a vehicle", () => { + expect(isVehicleActorAllowed("Retired", "admin")).toBe(true) + expect(isVehicleActorAllowed("Retired", "system")).toBe(false) + }) + + it("admin and system can move vehicle to Available", () => { + expect(isVehicleActorAllowed("Available", "admin")).toBe(true) + expect(isVehicleActorAllowed("Available", "system")).toBe(true) + }) + }) + + describe("isTerminalVehicleStatus", () => { + it("marks Retired as terminal", () => { + expect(isTerminalVehicleStatus("Retired")).toBe(true) + }) + + it("does not mark non-Retired statuses as terminal", () => { + expect(isTerminalVehicleStatus("Available")).toBe(false) + expect(isTerminalVehicleStatus("Reserved")).toBe(false) + expect(isTerminalVehicleStatus("Financed")).toBe(false) + expect(isTerminalVehicleStatus("Maintenance")).toBe(false) + }) + }) + + describe("isValidVehicleFundingTransition", () => { + it("allows same-status no-op", () => { + for (const status of ALL_FUNDING_STATUSES) { + expect(isValidVehicleFundingTransition(status, status)).toBe(true) + } + }) + + it("walks the funding lifecycle Open -> Funded -> Active", () => { + expect(isValidVehicleFundingTransition("Open", "Funded")).toBe(true) + expect(isValidVehicleFundingTransition("Funded", "Active")).toBe(true) + }) + + it("allows reverting from Funded to Open (loan rejected before activation)", () => { + expect(isValidVehicleFundingTransition("Funded", "Open")).toBe(true) + }) + + it("allows admin override from Active back to Funded", () => { + expect(isValidVehicleFundingTransition("Active", "Funded")).toBe(true) + }) + + it("rejects skipping from Open directly to Active", () => { + expect(isValidVehicleFundingTransition("Open", "Active")).toBe(false) + }) + + it("every listed transition target is a known funding status", () => { + for (const [from, targets] of Object.entries(VEHICLE_FUNDING_TRANSITIONS)) { + expect(ALL_FUNDING_STATUSES).toContain(from as VehicleFundingStatus) + for (const target of targets) { + expect(ALL_FUNDING_STATUSES).toContain(target) + } + } + }) + }) +}) diff --git a/__tests__/lib/domain/vehicle-transition-service.test.ts b/__tests__/lib/domain/vehicle-transition-service.test.ts new file mode 100644 index 00000000..1ab00071 --- /dev/null +++ b/__tests__/lib/domain/vehicle-transition-service.test.ts @@ -0,0 +1,283 @@ +// @vitest-environment node +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest" +import mongoose from "mongoose" + +import Vehicle from "@/models/Vehicle" +import Loan from "@/models/Loan" +import User from "@/models/User" +import StateTransitionHistory from "@/models/StateTransitionHistory" +import { + transitionVehicle, + DomainTransitionError, + DomainConcurrencyError, +} from "@/lib/domain/vehicle-transition-service" + +const TEST_URI = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chainmove-test" + +beforeAll(async () => { + try { + await mongoose.connect(TEST_URI, { serverSelectionTimeoutMS: 3000 }) + } catch { + console.warn("MongoDB not available — skipping vehicle service tests") + } +}, 10000) + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close() + } +}) + +function skip() { + return mongoose.connection.readyState !== 1 +} + +async function makeVehicle(overrides: Record = {}) { + return Vehicle.create({ + name: "Test Shuttle", + type: "shuttle", + year: 2023, + price: 600000, + roi: 12, + status: "Available", + fundingStatus: "Open", + specifications: { + engine: "2.5L", + fuelType: "petrol", + mileage: "25km/l", + transmission: "automatic", + color: "blue", + vin: `VIN-V-${Date.now()}-${Math.random().toString(36).slice(2)}`, + }, + ...overrides, + }) +} + +async function makeUser(role = "admin") { + return User.create({ + name: `Vehicle Test ${role}`, + email: `v-${role}-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`, + role, + }) +} + +describe("Vehicle transition service", () => { + let adminUser: any + let driverUser: any + + beforeEach(async () => { + if (skip()) return + adminUser = await makeUser("admin") + driverUser = await makeUser("driver") + }) + + // ── Happy path ────────────────────────────────────────────────────────────── + + it("reserves an available vehicle", async () => { + if (skip()) return + const vehicle = await makeVehicle() + const result = await transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "reserve", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Loan application received", + }) + expect(result.previousStatus).toBe("Available") + expect(result.nextStatus).toBe("Reserved") + }) + + it("finalizes a reserved vehicle to Financed", async () => { + if (skip()) return + const vehicle = await makeVehicle({ status: "Reserved" }) + const result = await transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "assignDriver", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Loan activated", + driverId: driverUser._id.toString(), + }) + expect(result.nextStatus).toBe("Financed") + expect(result.vehicle.fundingStatus).toBe("Active") + }) + + it("releases a reservation back to Available", async () => { + if (skip()) return + const vehicle = await makeVehicle({ status: "Reserved", driverId: driverUser._id }) + const result = await transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "releaseReservation", + actor: { type: "system" }, + reason: "Loan rejected", + }) + expect(result.nextStatus).toBe("Available") + const updated = await Vehicle.findById(vehicle._id) + expect(updated!.driverId).toBeUndefined() + }) + + it("enters and exits maintenance", async () => { + if (skip()) return + const vehicle = await makeVehicle() + await transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "enterMaintenance", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Scheduled service", + }) + let updated = await Vehicle.findById(vehicle._id) + expect(updated!.status).toBe("Maintenance") + + await transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "exitMaintenance", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Service complete", + }) + updated = await Vehicle.findById(vehicle._id) + expect(updated!.status).toBe("Available") + }) + + it("retires a vehicle", async () => { + if (skip()) return + const vehicle = await makeVehicle() + const result = await transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "retire", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "End of service life", + }) + expect(result.nextStatus).toBe("Retired") + }) + + it("records transition history", async () => { + if (skip()) return + const vehicle = await makeVehicle() + await transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "reserve", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "History test", + }) + const history = await StateTransitionHistory.findOne({ + entityType: "vehicle", + entityId: vehicle._id, + }) + expect(history).not.toBeNull() + expect(history!.fromState).toBe("Available") + expect(history!.toState).toBe("Reserved") + }) + + // ── Forbidden transitions ────────────────────────────────────────────────── + + it("rejects any transition from Retired (terminal)", async () => { + if (skip()) return + const vehicle = await makeVehicle({ status: "Retired" }) + await expect( + transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "reserve", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Trying to reactivate retired vehicle", + }), + ).rejects.toMatchObject({ code: "INVALID_TRANSITION" }) + }) + + it("rejects skipping reservation (Available -> Financed)", async () => { + if (skip()) return + const vehicle = await makeVehicle() + await expect( + transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "assignDriver", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Trying to skip reservation", + driverId: driverUser._id.toString(), + }), + ).rejects.toMatchObject({ code: "INVALID_TRANSITION" }) + }) + + it("rejects finalize without driverId", async () => { + if (skip()) return + const vehicle = await makeVehicle({ status: "Reserved" }) + await expect( + transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "assignDriver", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Missing driverId", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }) + }) + + it("rejects entering maintenance while active loan exists", async () => { + if (skip()) return + const vehicle = await makeVehicle({ status: "Financed" }) + const driver = await makeUser("driver") + await Loan.create({ + driverId: driver._id, + vehicleId: vehicle._id, + requestedAmount: 600000, + totalFunded: 600000, + fundingProgress: 100, + loanTerm: 12, + monthlyPayment: 55000, + weeklyPayment: 14000, + interestRate: 8, + status: "Active", + version: 0, + }) + await expect( + transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "enterMaintenance", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Trying maintenance during active loan", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }) + }) + + it("rejects stale version (concurrent transition)", async () => { + if (skip()) return + const vehicle = await makeVehicle() + await transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "reserve", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "First transition", + }) + await expect( + transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "releaseReservation", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "Concurrent release", + expectedVersion: 0, + }), + ).rejects.toBeInstanceOf(DomainConcurrencyError) + }) + + it("requires a reason", async () => { + if (skip()) return + const vehicle = await makeVehicle() + await expect( + transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "reserve", + actor: { type: "admin", id: adminUser._id.toString() }, + reason: "", + }), + ).rejects.toMatchObject({ code: "REASON_REQUIRED" }) + }) + + it("system cannot enter maintenance (admin-only command)", async () => { + if (skip()) return + const vehicle = await makeVehicle() + await expect( + transitionVehicle({ + vehicleId: vehicle._id.toString(), + command: "enterMaintenance", + actor: { type: "system" }, + reason: "System-triggered maintenance", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN_ACTOR" }) + }) +}) diff --git a/app/api/investments/[id]/transition/route.ts b/app/api/investments/[id]/transition/route.ts new file mode 100644 index 00000000..6ff55ec8 --- /dev/null +++ b/app/api/investments/[id]/transition/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server" +import mongoose from "mongoose" + +import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import dbConnect from "@/lib/dbConnect" +import { + transitionInvestment, + DomainTransitionError, + DomainConcurrencyError, + type InvestmentCommand, +} from "@/lib/domain/investment-transition-service" + +const VALID_COMMANDS = new Set(["activate", "complete"]) + +function isObjectId(value: unknown): value is string { + return typeof value === "string" && mongoose.Types.ObjectId.isValid(value) +} + +async function requireAdmin(request: Request) { + const authContext = await getAuthenticatedUser(request) + if (!authContext.user) { + return { response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + } + if (authContext.user.role !== "admin") { + return { response: NextResponse.json({ error: "Admin access required" }, { status: 403 }) } + } + return authContext +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + await dbConnect() + + const { id } = await params + if (!isObjectId(id)) { + return NextResponse.json({ error: "Invalid investment id" }, { status: 400 }) + } + + const body = await request.json().catch(() => ({})) + const command = typeof body.command === "string" ? body.command : "" + const reason = typeof body.reason === "string" ? body.reason.trim() : "" + const expectedVersion = + typeof body.expectedVersion === "number" ? body.expectedVersion : undefined + + if (!VALID_COMMANDS.has(command as InvestmentCommand)) { + return NextResponse.json( + { + error: `Invalid command '${command}'. Valid commands: ${[...VALID_COMMANDS].join(", ")}`, + }, + { status: 400 }, + ) + } + + if (!reason) { + return NextResponse.json({ error: "A reason is required." }, { status: 400 }) + } + + const authContext = await requireAdmin(request) + if ("response" in authContext) return authContext.response + + const result = await transitionInvestment({ + investmentId: id, + command: command as InvestmentCommand, + actor: { type: "admin", id: authContext.user!._id.toString() }, + reason, + expectedVersion, + }) + + const response = NextResponse.json( + { + message: `Investment transitioned to '${result.nextStatus}'.`, + investment: result.investment, + previousStatus: result.previousStatus, + nextStatus: result.nextStatus, + }, + { status: 200 }, + ) + + return authContext.shouldRefreshSession ? withSessionRefresh(response, authContext.user) : response + } catch (err) { + if (err instanceof DomainConcurrencyError) { + return NextResponse.json({ error: err.message, code: err.code }, { status: 409 }) + } + if (err instanceof DomainTransitionError) { + const statusMap: Record = { + NOT_FOUND: 404, + INVALID_TRANSITION: 409, + FORBIDDEN_ACTOR: 403, + PRECONDITION_FAILED: 422, + REASON_REQUIRED: 400, + INVALID_INPUT: 400, + } + return NextResponse.json( + { error: err.message, code: err.code }, + { status: statusMap[err.code] ?? 400 }, + ) + } + console.error("INVESTMENT_TRANSITION_ERROR", err) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/app/api/loans/[id]/transition/route.ts b/app/api/loans/[id]/transition/route.ts new file mode 100644 index 00000000..51221f5d --- /dev/null +++ b/app/api/loans/[id]/transition/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from "next/server" +import mongoose from "mongoose" + +import { authorizeRequest } from "@/lib/authorization/route" +import { withSessionRefresh } from "@/lib/auth/current-user" +import dbConnect from "@/lib/dbConnect" +import { transitionLoan, DomainTransitionError, DomainConcurrencyError } from "@/lib/domain/loan-transition-service" +import { type LoanCommand } from "@/lib/domain/loan-transition-service" + +const VALID_COMMANDS = new Set([ + "startReview", + "approve", + "reject", + "activate", + "complete", + "cancel", +]) + +const COMMAND_ACTIONS: Record = { + startReview: "loan:approve", + approve: "loan:approve", + reject: "loan:approve", + activate: "loan:approve", + complete: "loan:approve", + cancel: "loan:cancel", +} + +function isObjectId(value: unknown): value is string { + return typeof value === "string" && mongoose.Types.ObjectId.isValid(value) +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + await dbConnect() + + const { id } = await params + if (!isObjectId(id)) { + return NextResponse.json({ error: "Invalid loan id" }, { status: 400 }) + } + + const body = await request.json().catch(() => ({})) + const command = typeof body.command === "string" ? body.command : "" + const reason = typeof body.reason === "string" ? body.reason.trim() : "" + const adminNotes = typeof body.adminNotes === "string" ? body.adminNotes.trim() : undefined + const expectedVersion = + typeof body.expectedVersion === "number" ? body.expectedVersion : undefined + + if (!VALID_COMMANDS.has(command as LoanCommand)) { + return NextResponse.json( + { error: `Invalid command '${command}'. Valid commands: ${[...VALID_COMMANDS].join(", ")}` }, + { status: 400 }, + ) + } + + if (!reason) { + return NextResponse.json({ error: "A reason is required." }, { status: 400 }) + } + + const typedCommand = command as LoanCommand + const auth = await authorizeRequest(request, COMMAND_ACTIONS[typedCommand] as any, { + type: "loan", + }) + if ("response" in auth) return auth.response + const { user, shouldRefreshSession } = auth + + const actorType = user.role === "admin" ? "admin" : "driver" + + const result = await transitionLoan({ + loanId: id, + command: typedCommand, + actor: { type: actorType, id: user._id.toString() }, + reason, + adminNotes, + expectedVersion, + }) + + const response = NextResponse.json( + { + message: `Loan transitioned to '${result.nextStatus}'.`, + loan: { ...result.loan.toObject(), id: result.loan._id.toString() }, + previousStatus: result.previousStatus, + nextStatus: result.nextStatus, + }, + { status: 200 }, + ) + + return shouldRefreshSession ? withSessionRefresh(response, user) : response + } catch (err) { + if (err instanceof DomainConcurrencyError) { + return NextResponse.json({ error: err.message, code: err.code }, { status: 409 }) + } + if (err instanceof DomainTransitionError) { + const statusMap: Record = { + NOT_FOUND: 404, + INVALID_TRANSITION: 409, + FORBIDDEN_ACTOR: 403, + PRECONDITION_FAILED: 422, + REASON_REQUIRED: 400, + INVALID_INPUT: 400, + } + return NextResponse.json( + { error: err.message, code: err.code }, + { status: statusMap[err.code] ?? 400 }, + ) + } + console.error("LOAN_TRANSITION_ERROR", err) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/app/api/vehicles/[id]/route.ts b/app/api/vehicles/[id]/route.ts index 1063a6fb..8697f982 100644 --- a/app/api/vehicles/[id]/route.ts +++ b/app/api/vehicles/[id]/route.ts @@ -7,9 +7,6 @@ import { logAuditEvent } from "@/lib/security/audit-log" import { getClientIpAddress } from "@/lib/security/rate-limit" import Vehicle from "@/models/Vehicle" -const VEHICLE_STATUS_VALUES = ["Available", "Financed", "Reserved", "Maintenance", "Retired"] as const -const FUNDING_STATUS_VALUES = ["Open", "Funded", "Active"] as const - function isObjectId(value: unknown): value is string { return typeof value === "string" && mongoose.Types.ObjectId.isValid(value) } @@ -66,21 +63,8 @@ function buildVehicleUpdatePayload(body: unknown) { if ("image" in body) payload.image = normalizeString(body.image) || undefined - if ("status" in body) { - const status = normalizeString(body.status) - if (!VEHICLE_STATUS_VALUES.includes(status as (typeof VEHICLE_STATUS_VALUES)[number])) { - throw new Error("Invalid status.") - } - payload.status = status - } - - if ("fundingStatus" in body) { - const fundingStatus = normalizeString(body.fundingStatus) - if (!FUNDING_STATUS_VALUES.includes(fundingStatus as (typeof FUNDING_STATUS_VALUES)[number])) { - throw new Error("Invalid funding status.") - } - payload.fundingStatus = fundingStatus - } + // status and fundingStatus are intentionally omitted — all status changes must + // go through the /api/vehicles/[id]/transition endpoint. if ("totalFundedAmount" in body) { const totalFundedAmount = normalizeNumber(body.totalFundedAmount) diff --git a/app/api/vehicles/[id]/transition/route.ts b/app/api/vehicles/[id]/transition/route.ts new file mode 100644 index 00000000..5d1e1e16 --- /dev/null +++ b/app/api/vehicles/[id]/transition/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server" +import mongoose from "mongoose" + +import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import dbConnect from "@/lib/dbConnect" +import { + transitionVehicle, + DomainTransitionError, + DomainConcurrencyError, + type VehicleCommand, +} from "@/lib/domain/vehicle-transition-service" + +const VALID_COMMANDS = new Set([ + "reserve", + "assignDriver", + "releaseReservation", + "finalize", + "enterMaintenance", + "exitMaintenance", + "retire", +]) + +function isObjectId(value: unknown): value is string { + return typeof value === "string" && mongoose.Types.ObjectId.isValid(value) +} + +async function requireAdmin(request: Request) { + const authContext = await getAuthenticatedUser(request) + if (!authContext.user) { + return { response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + } + if (authContext.user.role !== "admin") { + return { response: NextResponse.json({ error: "Admin access required" }, { status: 403 }) } + } + return authContext +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + await dbConnect() + + const { id } = await params + if (!isObjectId(id)) { + return NextResponse.json({ error: "Invalid vehicle id" }, { status: 400 }) + } + + const body = await request.json().catch(() => ({})) + const command = typeof body.command === "string" ? body.command : "" + const reason = typeof body.reason === "string" ? body.reason.trim() : "" + const driverId = typeof body.driverId === "string" ? body.driverId : undefined + const expectedVersion = + typeof body.expectedVersion === "number" ? body.expectedVersion : undefined + + if (!VALID_COMMANDS.has(command as VehicleCommand)) { + return NextResponse.json( + { + error: `Invalid command '${command}'. Valid commands: ${[...VALID_COMMANDS].join(", ")}`, + }, + { status: 400 }, + ) + } + + if (!reason) { + return NextResponse.json({ error: "A reason is required." }, { status: 400 }) + } + + const authContext = await requireAdmin(request) + if ("response" in authContext) return authContext.response + + const result = await transitionVehicle({ + vehicleId: id, + command: command as VehicleCommand, + actor: { type: "admin", id: authContext.user!._id.toString() }, + reason, + driverId, + expectedVersion, + }) + + const response = NextResponse.json( + { + message: `Vehicle transitioned to '${result.nextStatus}'.`, + vehicle: result.vehicle, + previousStatus: result.previousStatus, + nextStatus: result.nextStatus, + }, + { status: 200 }, + ) + + return authContext.shouldRefreshSession ? withSessionRefresh(response, authContext.user) : response + } catch (err) { + if (err instanceof DomainConcurrencyError) { + return NextResponse.json({ error: err.message, code: err.code }, { status: 409 }) + } + if (err instanceof DomainTransitionError) { + const statusMap: Record = { + NOT_FOUND: 404, + INVALID_TRANSITION: 409, + FORBIDDEN_ACTOR: 403, + PRECONDITION_FAILED: 422, + REASON_REQUIRED: 400, + INVALID_INPUT: 400, + } + return NextResponse.json( + { error: err.message, code: err.code }, + { status: statusMap[err.code] ?? 400 }, + ) + } + console.error("VEHICLE_TRANSITION_ERROR", err) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/lib/domain/investment-state-machine.ts b/lib/domain/investment-state-machine.ts new file mode 100644 index 00000000..17f87652 --- /dev/null +++ b/lib/domain/investment-state-machine.ts @@ -0,0 +1,42 @@ +export type InvestmentStatus = "Funding" | "Active" | "Completed" +export type InvestmentActorType = "admin" | "system" + +/** + * Authoritative transition table for the direct-vehicle Investment lifecycle. + * + * Funding is the initial state when an investment is recorded but the + * underlying loan has not yet been activated. Once the loan activates, all + * investments for that vehicle move to Active. When the loan completes, they + * move to Completed. Completed is terminal. + */ +export const INVESTMENT_VALID_TRANSITIONS: Record = { + Funding: ["Active"], + Active: ["Completed"], + Completed: [], +} + +export const INVESTMENT_TRANSITION_ACTORS: Record = { + Funding: [], + Active: ["admin", "system"], + Completed: ["admin", "system"], +} + +export function isValidInvestmentTransition( + from: InvestmentStatus | null, + to: InvestmentStatus, +): boolean { + if (!from) return true + if (from === to) return true + return (INVESTMENT_VALID_TRANSITIONS[from] || []).includes(to) +} + +export function isInvestmentActorAllowed( + targetState: InvestmentStatus, + actorType: InvestmentActorType, +): boolean { + return (INVESTMENT_TRANSITION_ACTORS[targetState] || []).includes(actorType) +} + +export function isTerminalInvestmentState(state: InvestmentStatus): boolean { + return state === "Completed" +} diff --git a/lib/domain/investment-transition-service.ts b/lib/domain/investment-transition-service.ts new file mode 100644 index 00000000..c7246115 --- /dev/null +++ b/lib/domain/investment-transition-service.ts @@ -0,0 +1,174 @@ +import mongoose, { type ClientSession } from "mongoose" + +import dbConnect from "@/lib/dbConnect" +import { logAuditEvent } from "@/lib/security/audit-log" +import { + isValidInvestmentTransition, + isInvestmentActorAllowed, + isTerminalInvestmentState, + type InvestmentStatus, + type InvestmentActorType, +} from "@/lib/domain/investment-state-machine" +import { DomainTransitionError, DomainConcurrencyError, isWriteConflict } from "@/lib/domain/transition-error" +import Investment from "@/models/Investment" +import StateTransitionHistory from "@/models/StateTransitionHistory" + +export { DomainTransitionError, DomainConcurrencyError } + +export type InvestmentCommand = "activate" | "complete" + +const COMMAND_TARGET: Record = { + activate: "Active", + complete: "Completed", +} + +export interface InvestmentTransitionInput { + investmentId: string + command: InvestmentCommand + actor: { type: InvestmentActorType; id?: string } + reason: string + expectedVersion?: number + correlationId?: string + metadata?: Record + session?: ClientSession +} + +export interface InvestmentTransitionResult { + investment: any + previousStatus: InvestmentStatus + nextStatus: InvestmentStatus +} + +function toObjectId(value: string, label: string) { + if (!mongoose.Types.ObjectId.isValid(value)) { + throw new DomainTransitionError("INVALID_INPUT", `Invalid ${label}.`) + } + return new mongoose.Types.ObjectId(value) +} + +async function runInSession( + existing: ClientSession | undefined, + fn: (session: ClientSession) => Promise, +): Promise { + if (existing) return fn(existing) + + const session = await mongoose.startSession() + session.startTransaction() + try { + const result = await fn(session) + await session.commitTransaction() + return result + } catch (err) { + await session.abortTransaction().catch(() => undefined) + throw err + } finally { + session.endSession() + } +} + +/** + * The single authoritative entry point for individual investment status changes. + * Bulk transitions (e.g., all investments for a vehicle when a loan activates) + * are handled directly by the loan transition service within its own session. + */ +export async function transitionInvestment( + input: InvestmentTransitionInput, +): Promise { + await dbConnect() + + if (!input.reason?.trim()) { + throw new DomainTransitionError("REASON_REQUIRED", "A reason is required for every investment state transition.") + } + + const investmentObjectId = toObjectId(input.investmentId, "investment id") + const targetStatus = COMMAND_TARGET[input.command] + + try { + return await runInSession(input.session, async (session) => { + const investment = await Investment.findById(investmentObjectId).session(session) + if (!investment) throw new DomainTransitionError("NOT_FOUND", "Investment not found.") + + const from = (investment.status || "Funding") as InvestmentStatus + const to = targetStatus + + if (from === to) { + return { investment, previousStatus: from, nextStatus: to } + } + + if (isTerminalInvestmentState(from)) { + throw new DomainTransitionError( + "INVALID_TRANSITION", + `Investment is in a terminal state '${from}' and cannot be transitioned.`, + ) + } + + if (!isValidInvestmentTransition(from, to)) { + throw new DomainTransitionError( + "INVALID_TRANSITION", + `Cannot transition investment from '${from}' to '${to}'.`, + ) + } + + if (!isInvestmentActorAllowed(to, input.actor.type)) { + throw new DomainTransitionError( + "FORBIDDEN_ACTOR", + `Actor type '${input.actor.type}' is not permitted to transition an investment to '${to}'.`, + ) + } + + const expectedVersion = input.expectedVersion ?? (investment.version ?? 0) + const versionCondition = + expectedVersion === 0 + ? { $or: [{ version: 0 }, { version: { $exists: false } }] } + : { version: expectedVersion } + + const updated = await Investment.findOneAndUpdate( + { _id: investment._id, ...versionCondition }, + { $set: { status: to }, $inc: { version: 1 } }, + { session, new: true }, + ) + + if (!updated) throw new DomainConcurrencyError("investment") + + await StateTransitionHistory.create( + [ + { + entityType: "investment", + entityId: investment._id, + fromState: from, + toState: to, + actorType: input.actor.type, + actorId: input.actor.id ? toObjectId(input.actor.id, "actor id") : undefined, + reason: input.reason.trim(), + correlationId: input.correlationId, + metadata: input.metadata, + timestamp: new Date(), + }, + ], + { session }, + ) + + await logAuditEvent({ + actor: input.actor.id + ? { _id: { toString: () => input.actor.id as string }, role: input.actor.type } + : null, + action: `investment.transition.${to.toLowerCase()}`, + targetType: "investment", + targetId: investment._id.toString(), + metadata: { + fromState: from, + toState: to, + command: input.command, + reason: input.reason, + actorType: input.actor.type, + correlationId: input.correlationId, + }, + }) + + return { investment: updated, previousStatus: from, nextStatus: to } + }) + } catch (err) { + if (isWriteConflict(err)) throw new DomainConcurrencyError("investment") + throw err + } +} diff --git a/lib/domain/loan-state-machine.ts b/lib/domain/loan-state-machine.ts new file mode 100644 index 00000000..169725b5 --- /dev/null +++ b/lib/domain/loan-state-machine.ts @@ -0,0 +1,73 @@ +export type LoanStatus = + | "Pending" + | "Under Review" + | "Approved" + | "Rejected" + | "Active" + | "Completed" + | "Cancelled" + +export type LoanActorType = "driver" | "admin" | "system" + +/** + * Authoritative transition table for the loan lifecycle. + * + * Rejected, Completed, and Cancelled are terminal — once reached, no further + * ordinary transitions are permitted. An admin override path is intentionally + * omitted here; administrative corrections must use a separate, audited command. + */ +export const LOAN_VALID_TRANSITIONS: Record = { + Pending: ["Under Review", "Rejected", "Cancelled"], + "Under Review": ["Approved", "Rejected", "Cancelled"], + Approved: ["Active", "Rejected", "Cancelled"], + Active: ["Completed"], + Rejected: [], + Completed: [], + Cancelled: [], +} + +// Which actor types may drive a transition INTO a given target status. +export const LOAN_TRANSITION_ACTORS: Record = { + Pending: [], + "Under Review": ["admin"], + Approved: ["admin"], + Rejected: ["admin"], + Active: ["admin", "system"], + Completed: ["admin", "system"], + Cancelled: ["admin", "driver"], +} + +export const TERMINAL_LOAN_STATES: LoanStatus[] = ["Rejected", "Completed", "Cancelled"] + +export const PRE_ACTIVE_LOAN_STATES: LoanStatus[] = ["Pending", "Under Review", "Approved"] + +export function isValidLoanTransition( + from: LoanStatus | null, + to: LoanStatus, +): boolean { + if (!from) return true + if (from === to) return true + return (LOAN_VALID_TRANSITIONS[from] || []).includes(to) +} + +export function isLoanActorAllowed( + targetState: LoanStatus, + fromState: LoanStatus | null, + actorType: LoanActorType, +): boolean { + const allowed = LOAN_TRANSITION_ACTORS[targetState] || [] + if (!allowed.includes(actorType)) return false + // Drivers may only cancel before the loan becomes active. + if (targetState === "Cancelled" && actorType === "driver") { + return fromState !== null && PRE_ACTIVE_LOAN_STATES.includes(fromState) + } + return true +} + +export function isTerminalLoanState(state: LoanStatus): boolean { + return TERMINAL_LOAN_STATES.includes(state) +} + +export function isRepayableLoanState(state: LoanStatus | string | null | undefined): boolean { + return state === "Active" +} diff --git a/lib/domain/loan-transition-service.ts b/lib/domain/loan-transition-service.ts new file mode 100644 index 00000000..8e9d1f76 --- /dev/null +++ b/lib/domain/loan-transition-service.ts @@ -0,0 +1,270 @@ +import mongoose, { type ClientSession } from "mongoose" + +import dbConnect from "@/lib/dbConnect" +import { logAuditEvent } from "@/lib/security/audit-log" +import { + isValidLoanTransition, + isLoanActorAllowed, + isTerminalLoanState, + type LoanStatus, + type LoanActorType, +} from "@/lib/domain/loan-state-machine" +import { DomainTransitionError, DomainConcurrencyError, isWriteConflict } from "@/lib/domain/transition-error" +import Loan from "@/models/Loan" +import Vehicle from "@/models/Vehicle" +import Investment from "@/models/Investment" +import StateTransitionHistory from "@/models/StateTransitionHistory" + +export { DomainTransitionError, DomainConcurrencyError } + +export type LoanCommand = + | "startReview" + | "approve" + | "reject" + | "activate" + | "complete" + | "cancel" + +const COMMAND_TARGET: Record = { + startReview: "Under Review", + approve: "Approved", + reject: "Rejected", + activate: "Active", + complete: "Completed", + cancel: "Cancelled", +} + +export interface LoanTransitionInput { + loanId: string + command: LoanCommand + actor: { type: LoanActorType; id?: string } + reason: string + expectedVersion?: number + adminNotes?: string + correlationId?: string + metadata?: Record + session?: ClientSession +} + +export interface LoanTransitionResult { + loan: any + previousStatus: LoanStatus + nextStatus: LoanStatus +} + +function toObjectId(value: string, label: string) { + if (!mongoose.Types.ObjectId.isValid(value)) { + throw new DomainTransitionError("INVALID_INPUT", `Invalid ${label}.`) + } + return new mongoose.Types.ObjectId(value) +} + +async function runInSession( + existing: ClientSession | undefined, + fn: (session: ClientSession) => Promise, +): Promise { + if (existing) return fn(existing) + + const session = await mongoose.startSession() + session.startTransaction() + try { + const result = await fn(session) + await session.commitTransaction() + return result + } catch (err) { + await session.abortTransaction().catch(() => undefined) + throw err + } finally { + session.endSession() + } +} + +/** + * The single authoritative entry point for loan status changes. + * Every status mutation must go through this function — direct field writes + * on the Loan document are not supported outside of this service. + */ +export async function transitionLoan(input: LoanTransitionInput): Promise { + await dbConnect() + + if (!input.reason?.trim()) { + throw new DomainTransitionError("REASON_REQUIRED", "A reason is required for every loan state transition.") + } + + const loanObjectId = toObjectId(input.loanId, "loan id") + const targetStatus = COMMAND_TARGET[input.command] + + try { + return await runInSession(input.session, async (session) => { + const loan = await Loan.findById(loanObjectId).session(session) + if (!loan) throw new DomainTransitionError("NOT_FOUND", "Loan not found.") + + const from = loan.status as LoanStatus + const to = targetStatus + + if (from === to) { + return { loan, previousStatus: from, nextStatus: to } + } + + if (isTerminalLoanState(from)) { + throw new DomainTransitionError( + "INVALID_TRANSITION", + `Loan is in a terminal state '${from}' and cannot be transitioned.`, + ) + } + + if (!isValidLoanTransition(from, to)) { + throw new DomainTransitionError( + "INVALID_TRANSITION", + `Cannot transition loan from '${from}' to '${to}'.`, + ) + } + + if (!isLoanActorAllowed(to, from, input.actor.type)) { + throw new DomainTransitionError( + "FORBIDDEN_ACTOR", + `Actor type '${input.actor.type}' is not permitted to transition a loan from '${from}' to '${to}'.`, + ) + } + + const setFields: Record = { status: to } + const sideEffects: Array<() => Promise> = [] + + if (to === "Under Review") { + setFields.reviewedDate = new Date() + } + + if (to === "Approved") { + setFields.approvedDate = new Date() + if (input.adminNotes) setFields.adminNotes = input.adminNotes + } + + if (to === "Rejected") { + if (input.adminNotes) setFields.adminNotes = input.adminNotes + // Release vehicle back to Available when rejecting + sideEffects.push(async () => { + await Vehicle.findOneAndUpdate( + { _id: loan.vehicleId }, + { $set: { status: "Available" }, $unset: { driverId: 1 } }, + { session }, + ) + }) + } + + if (to === "Active") { + // Precondition: loan must be fully funded + if (Number(loan.totalFunded || 0) < Number(loan.requestedAmount || 0)) { + throw new DomainTransitionError( + "PRECONDITION_FAILED", + `Loan cannot be activated: funding is ${loan.totalFunded} of ${loan.requestedAmount} required.`, + ) + } + // Precondition: down payment must be made + if (!loan.downPaymentMade) { + throw new DomainTransitionError( + "PRECONDITION_FAILED", + "Loan cannot be activated: down payment has not been recorded.", + ) + } + sideEffects.push(async () => { + await Vehicle.findOneAndUpdate( + { _id: loan.vehicleId }, + { $set: { status: "Financed", fundingStatus: "Active", driverId: loan.driverId } }, + { session }, + ) + // Move all investments for this vehicle from Funding to Active + await Investment.updateMany( + { vehicleId: loan.vehicleId, status: "Funding" }, + { $set: { status: "Active" }, $inc: { version: 1 } }, + { session }, + ) + }) + } + + if (to === "Completed") { + sideEffects.push(async () => { + await Vehicle.findOneAndUpdate( + { _id: loan.vehicleId }, + { $set: { status: "Available", fundingStatus: "Open" }, $unset: { driverId: 1 } }, + { session }, + ) + // Complete all active investments tied to this loan's vehicle + await Investment.updateMany( + { + $or: [{ loanId: loan._id }, { vehicleId: loan.vehicleId }], + status: "Active", + }, + { $set: { status: "Completed" }, $inc: { version: 1 } }, + { session }, + ) + }) + } + + if (to === "Cancelled") { + sideEffects.push(async () => { + await Vehicle.findOneAndUpdate( + { _id: loan.vehicleId }, + { $set: { status: "Available" }, $unset: { driverId: 1 } }, + { session }, + ) + }) + } + + const expectedVersion = input.expectedVersion ?? (loan.version ?? 0) + const versionCondition = + expectedVersion === 0 + ? { $or: [{ version: 0 }, { version: { $exists: false } }] } + : { version: expectedVersion } + + const updated = await Loan.findOneAndUpdate( + { _id: loan._id, ...versionCondition }, + { $set: setFields, $inc: { version: 1 } }, + { session, new: true }, + ) + + if (!updated) throw new DomainConcurrencyError("loan") + + for (const effect of sideEffects) await effect() + + await StateTransitionHistory.create( + [ + { + entityType: "loan", + entityId: loan._id, + fromState: from, + toState: to, + actorType: input.actor.type, + actorId: input.actor.id ? toObjectId(input.actor.id, "actor id") : undefined, + reason: input.reason.trim(), + correlationId: input.correlationId, + metadata: input.metadata, + timestamp: new Date(), + }, + ], + { session }, + ) + + await logAuditEvent({ + actor: input.actor.id + ? { _id: { toString: () => input.actor.id as string }, role: input.actor.type } + : null, + action: `loan.transition.${to.toLowerCase().replace(/\s+/g, "_")}`, + targetType: "loan", + targetId: loan._id.toString(), + metadata: { + fromState: from, + toState: to, + command: input.command, + reason: input.reason, + actorType: input.actor.type, + correlationId: input.correlationId, + }, + }) + + return { loan: updated, previousStatus: from, nextStatus: to } + }) + } catch (err) { + if (isWriteConflict(err)) throw new DomainConcurrencyError("loan") + throw err + } +} diff --git a/lib/domain/transition-error.ts b/lib/domain/transition-error.ts new file mode 100644 index 00000000..63eb2c17 --- /dev/null +++ b/lib/domain/transition-error.ts @@ -0,0 +1,32 @@ +export class DomainTransitionError extends Error { + code: string + + constructor(code: string, message: string) { + super(message) + this.name = "DomainTransitionError" + this.code = code + } +} + +export class DomainConcurrencyError extends DomainTransitionError { + entityType: string + + constructor(entityType: string) { + super( + "CONCURRENCY_CONFLICT", + `This ${entityType} was modified by another transition. Please retry.`, + ) + this.name = "DomainConcurrencyError" + this.entityType = entityType + } +} + +export function isWriteConflict(error: unknown): boolean { + if (!error || typeof error !== "object") return false + const err = error as { code?: number; codeName?: string; errorLabels?: string[] } + return ( + err.code === 112 || + err.codeName === "WriteConflict" || + (err.errorLabels || []).includes("TransientTransactionError") + ) +} diff --git a/lib/domain/vehicle-state-machine.ts b/lib/domain/vehicle-state-machine.ts new file mode 100644 index 00000000..35b44290 --- /dev/null +++ b/lib/domain/vehicle-state-machine.ts @@ -0,0 +1,67 @@ +export type VehicleStatus = "Available" | "Reserved" | "Financed" | "Maintenance" | "Retired" +export type VehicleFundingStatus = "Open" | "Funded" | "Active" +export type VehicleActorType = "admin" | "system" + +/** + * Authoritative transition table for vehicle operational status. + * + * Retired is terminal. Available is the natural resting state reached after + * loan completion, reservation release, or maintenance exit. + */ +export const VEHICLE_VALID_TRANSITIONS: Record = { + Available: ["Reserved", "Maintenance", "Retired"], + Reserved: ["Available", "Financed", "Maintenance"], + Financed: ["Available", "Maintenance", "Retired"], + Maintenance: ["Available", "Retired"], + Retired: [], +} + +// Which actors may initiate a transition INTO the given target status. +export const VEHICLE_TRANSITION_ACTORS: Record = { + Available: ["admin", "system"], + Reserved: ["admin", "system"], + Financed: ["admin", "system"], + Maintenance: ["admin"], + Retired: ["admin"], +} + +/** + * Authoritative transition table for vehicle funding status. + * Open -> Funded when the vehicle's price is fully covered by investments. + * Funded -> Active when the loan activates. + * Active -> Funded is an admin override for loan cancellations post-funding. + * Funded -> Open when investments are rolled back (loan rejected while funded). + */ +export const VEHICLE_FUNDING_TRANSITIONS: Record = { + Open: ["Funded"], + Funded: ["Active", "Open"], + Active: ["Funded"], +} + +export function isValidVehicleTransition( + from: VehicleStatus | null, + to: VehicleStatus, +): boolean { + if (!from) return true + if (from === to) return true + return (VEHICLE_VALID_TRANSITIONS[from] || []).includes(to) +} + +export function isVehicleActorAllowed( + targetState: VehicleStatus, + actorType: VehicleActorType, +): boolean { + return (VEHICLE_TRANSITION_ACTORS[targetState] || []).includes(actorType) +} + +export function isValidVehicleFundingTransition( + from: VehicleFundingStatus, + to: VehicleFundingStatus, +): boolean { + if (from === to) return true + return (VEHICLE_FUNDING_TRANSITIONS[from] || []).includes(to) +} + +export function isTerminalVehicleStatus(status: VehicleStatus): boolean { + return status === "Retired" +} diff --git a/lib/domain/vehicle-transition-service.ts b/lib/domain/vehicle-transition-service.ts new file mode 100644 index 00000000..407f6f82 --- /dev/null +++ b/lib/domain/vehicle-transition-service.ts @@ -0,0 +1,293 @@ +import mongoose, { type ClientSession } from "mongoose" + +import dbConnect from "@/lib/dbConnect" +import { logAuditEvent } from "@/lib/security/audit-log" +import { + isValidVehicleTransition, + isVehicleActorAllowed, + isTerminalVehicleStatus, + isValidVehicleFundingTransition, + type VehicleStatus, + type VehicleFundingStatus, + type VehicleActorType, +} from "@/lib/domain/vehicle-state-machine" +import { DomainTransitionError, DomainConcurrencyError, isWriteConflict } from "@/lib/domain/transition-error" +import Vehicle from "@/models/Vehicle" +import Loan from "@/models/Loan" +import StateTransitionHistory from "@/models/StateTransitionHistory" + +export { DomainTransitionError, DomainConcurrencyError } + +export type VehicleCommand = + | "reserve" + | "assignDriver" + | "releaseReservation" + | "finalize" + | "enterMaintenance" + | "exitMaintenance" + | "retire" + +const COMMAND_TARGET: Record = { + reserve: "Reserved", + assignDriver: "Financed", + releaseReservation: "Available", + finalize: "Available", + enterMaintenance: "Maintenance", + exitMaintenance: "Available", + retire: "Retired", +} + +export interface VehicleTransitionInput { + vehicleId: string + command: VehicleCommand + actor: { type: VehicleActorType; id?: string } + reason: string + expectedVersion?: number + driverId?: string + correlationId?: string + metadata?: Record + session?: ClientSession +} + +export interface VehicleTransitionResult { + vehicle: any + previousStatus: VehicleStatus + nextStatus: VehicleStatus +} + +export interface VehicleFundingTransitionInput { + vehicleId: string + targetFundingStatus: VehicleFundingStatus + actor: { type: VehicleActorType; id?: string } + reason: string + session?: ClientSession +} + +function toObjectId(value: string, label: string) { + if (!mongoose.Types.ObjectId.isValid(value)) { + throw new DomainTransitionError("INVALID_INPUT", `Invalid ${label}.`) + } + return new mongoose.Types.ObjectId(value) +} + +async function runInSession( + existing: ClientSession | undefined, + fn: (session: ClientSession) => Promise, +): Promise { + if (existing) return fn(existing) + + const session = await mongoose.startSession() + session.startTransaction() + try { + const result = await fn(session) + await session.commitTransaction() + return result + } catch (err) { + await session.abortTransaction().catch(() => undefined) + throw err + } finally { + session.endSession() + } +} + +/** + * The single authoritative entry point for vehicle operational status changes. + */ +export async function transitionVehicle( + input: VehicleTransitionInput, +): Promise { + await dbConnect() + + if (!input.reason?.trim()) { + throw new DomainTransitionError("REASON_REQUIRED", "A reason is required for every vehicle state transition.") + } + + const vehicleObjectId = toObjectId(input.vehicleId, "vehicle id") + const targetStatus = COMMAND_TARGET[input.command] + + try { + return await runInSession(input.session, async (session) => { + const vehicle = await Vehicle.findById(vehicleObjectId).session(session) + if (!vehicle) throw new DomainTransitionError("NOT_FOUND", "Vehicle not found.") + + const from = vehicle.status as VehicleStatus + const to = targetStatus + + if (from === to) { + return { vehicle, previousStatus: from, nextStatus: to } + } + + if (isTerminalVehicleStatus(from)) { + throw new DomainTransitionError( + "INVALID_TRANSITION", + `Vehicle is retired and cannot be transitioned.`, + ) + } + + if (!isValidVehicleTransition(from, to)) { + throw new DomainTransitionError( + "INVALID_TRANSITION", + `Cannot transition vehicle from '${from}' to '${to}'.`, + ) + } + + if (!isVehicleActorAllowed(to, input.actor.type)) { + throw new DomainTransitionError( + "FORBIDDEN_ACTOR", + `Actor type '${input.actor.type}' is not permitted to transition a vehicle to '${to}'.`, + ) + } + + const setFields: Record = { status: to } + const unsetFields: Record = {} + + if (to === "Reserved" && input.driverId) { + setFields.driverId = toObjectId(input.driverId, "driver id") + } + + if (to === "Financed") { + if (!input.driverId) { + throw new DomainTransitionError( + "PRECONDITION_FAILED", + "A driverId is required to finalize vehicle assignment.", + ) + } + setFields.driverId = toObjectId(input.driverId, "driver id") + setFields.fundingStatus = "Active" + } + + if (to === "Available") { + unsetFields.driverId = 1 + // Only reset fundingStatus if vehicle is leaving Financed/Reserved back to Available + if (from === "Financed") { + setFields.fundingStatus = "Open" + } + } + + if (to === "Maintenance") { + // Guard: cannot enter maintenance while carrying an active loan + const activeLoan = await Loan.findOne({ + vehicleId: vehicle._id, + status: "Active", + }) + .select("_id") + .session(session) + if (activeLoan) { + throw new DomainTransitionError( + "PRECONDITION_FAILED", + "Vehicle has an active loan and cannot enter maintenance without first completing or transferring the loan.", + ) + } + } + + const expectedVersion = input.expectedVersion ?? (vehicle.version ?? 0) + const versionCondition = + expectedVersion === 0 + ? { $or: [{ version: 0 }, { version: { $exists: false } }] } + : { version: expectedVersion } + + const updateOp: Record = { + $set: setFields, + $inc: { version: 1 }, + } + if (Object.keys(unsetFields).length > 0) updateOp.$unset = unsetFields + + const updated = await Vehicle.findOneAndUpdate( + { _id: vehicle._id, ...versionCondition }, + updateOp, + { session, new: true }, + ) + + if (!updated) throw new DomainConcurrencyError("vehicle") + + await StateTransitionHistory.create( + [ + { + entityType: "vehicle", + entityId: vehicle._id, + fromState: from, + toState: to, + actorType: input.actor.type, + actorId: input.actor.id ? toObjectId(input.actor.id, "actor id") : undefined, + reason: input.reason.trim(), + correlationId: input.correlationId, + metadata: input.metadata, + timestamp: new Date(), + }, + ], + { session }, + ) + + await logAuditEvent({ + actor: input.actor.id + ? { _id: { toString: () => input.actor.id as string }, role: input.actor.type } + : null, + action: `vehicle.transition.${to.toLowerCase()}`, + targetType: "vehicle", + targetId: vehicle._id.toString(), + metadata: { + fromState: from, + toState: to, + command: input.command, + reason: input.reason, + actorType: input.actor.type, + correlationId: input.correlationId, + }, + }) + + return { vehicle: updated, previousStatus: from, nextStatus: to } + }) + } catch (err) { + if (isWriteConflict(err)) throw new DomainConcurrencyError("vehicle") + throw err + } +} + +/** + * Transition vehicle funding status independently of operational status. + * This is driven by investment totals reaching the vehicle price threshold. + */ +export async function transitionVehicleFunding( + input: VehicleFundingTransitionInput, +): Promise<{ vehicle: any; previousFundingStatus: VehicleFundingStatus; nextFundingStatus: VehicleFundingStatus }> { + await dbConnect() + + const vehicleObjectId = toObjectId(input.vehicleId, "vehicle id") + + return runInSession(input.session, async (session) => { + const vehicle = await Vehicle.findById(vehicleObjectId).session(session) + if (!vehicle) throw new DomainTransitionError("NOT_FOUND", "Vehicle not found.") + + const from = vehicle.fundingStatus as VehicleFundingStatus + const to = input.targetFundingStatus + + if (from === to) return { vehicle, previousFundingStatus: from, nextFundingStatus: to } + + if (!isValidVehicleFundingTransition(from, to)) { + throw new DomainTransitionError( + "INVALID_TRANSITION", + `Cannot transition vehicle funding from '${from}' to '${to}'.`, + ) + } + + const updated = await Vehicle.findByIdAndUpdate( + vehicle._id, + { $set: { fundingStatus: to }, $inc: { version: 1 } }, + { session, new: true }, + ) + + if (!updated) throw new DomainConcurrencyError("vehicle") + + await logAuditEvent({ + actor: input.actor.id + ? { _id: { toString: () => input.actor.id as string }, role: input.actor.type } + : null, + action: `vehicle.funding.transition.${to.toLowerCase()}`, + targetType: "vehicle", + targetId: vehicle._id.toString(), + metadata: { fromFundingStatus: from, toFundingStatus: to, reason: input.reason }, + }) + + return { vehicle: updated, previousFundingStatus: from, nextFundingStatus: to } + }) +} diff --git a/models/Investment.ts b/models/Investment.ts index d6c767a6..cc072849 100644 --- a/models/Investment.ts +++ b/models/Investment.ts @@ -6,6 +6,7 @@ export interface IInvestment extends Document { vehicleId: Schema.Types.ObjectId; amount: number; status: 'Funding' | 'Active' | 'Completed'; + version: number; monthlyReturn: number; date: Date; } @@ -17,12 +18,13 @@ const InvestmentSchema: Schema = new Schema({ amount: { type: Number, required: true }, status: { type: String, - enum: ['Active', 'Completed'], - default: 'Active', + enum: ['Funding', 'Active', 'Completed'], + default: 'Funding', }, + version: { type: Number, default: 0 }, monthlyReturn: { type: Number, required: true }, date: { type: Date, default: Date.now }, }); -export default (mongoose.models.Investment || +export default (mongoose.models.Investment || mongoose.model('Investment', InvestmentSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/Loan.ts b/models/Loan.ts index 7678e870..9b1bbe50 100644 --- a/models/Loan.ts +++ b/models/Loan.ts @@ -1,5 +1,5 @@ -import mongoose, { Document, Schema } from 'mongoose'; - +import mongoose, { Document, Schema } from 'mongoose'; + export interface ILoan extends Document { driverId: Schema.Types.ObjectId; vehicleId: Schema.Types.ObjectId; @@ -7,7 +7,8 @@ export interface ILoan extends Document { totalAmountToPayBack?: number; totalFunded: number; fundingProgress: number; - status: 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Active' | 'Completed'; + status: 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Active' | 'Completed' | 'Cancelled'; + version: number; loanTerm: number; // in months monthlyPayment: number; weeklyPayment?: number; @@ -25,19 +26,20 @@ export interface ILoan extends Document { downPaymentDate?: Date; investorApprovals: Schema.Types.ObjectId[]; } - -const LoanSchema: Schema = new Schema({ - driverId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + +const LoanSchema: Schema = new Schema({ + driverId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, vehicleId: { type: Schema.Types.ObjectId, ref: 'Vehicle', required: true }, requestedAmount: { type: Number, required: true }, totalAmountToPayBack: { type: Number }, totalFunded: { type: Number, default: 0 }, fundingProgress: { type: Number, default: 0 }, status: { - type: String, - enum: ['Pending', 'Under Review', 'Approved', 'Rejected', 'Active', 'Completed'], - default: 'Pending', + type: String, + enum: ['Pending', 'Under Review', 'Approved', 'Rejected', 'Active', 'Completed', 'Cancelled'], + default: 'Pending', }, + version: { type: Number, default: 0 }, loanTerm: { type: Number, required: true }, monthlyPayment: { type: Number, required: true }, weeklyPayment: { type: Number }, @@ -58,12 +60,12 @@ const LoanSchema: Schema = new Schema({ downPaymentDate: { type: Date }, investorApprovals: [{ type: Schema.Types.ObjectId, ref: 'User' }], }); - + // Pre-save middleware to calculate remaining amount LoanSchema.virtual('remainingAmount').get(function() { const loan = this as unknown as { requestedAmount?: number; totalFunded?: number } return Number(loan.requestedAmount || 0) - Number(loan.totalFunded || 0) }); - + export default (mongoose.models.Loan || mongoose.model('Loan', LoanSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; diff --git a/models/StateTransitionHistory.ts b/models/StateTransitionHistory.ts new file mode 100644 index 00000000..46f2738b --- /dev/null +++ b/models/StateTransitionHistory.ts @@ -0,0 +1,57 @@ +import mongoose, { Document, Schema } from "mongoose" + +export type TransitionEntityType = "loan" | "vehicle" | "investment" +export type TransitionActorType = "driver" | "admin" | "investor" | "system" + +export interface IStateTransitionHistory extends Document { + entityType: TransitionEntityType + entityId: Schema.Types.ObjectId + fromState: string | null + toState: string + actorType: TransitionActorType + actorId?: Schema.Types.ObjectId + reason: string + correlationId?: string + metadata?: Record + timestamp: Date +} + +const StateTransitionHistorySchema = new Schema( + { + entityType: { + type: String, + enum: ["loan", "vehicle", "investment"], + required: true, + index: true, + }, + entityId: { + type: Schema.Types.ObjectId, + required: true, + index: true, + }, + fromState: { type: String, default: null }, + toState: { type: String, required: true }, + actorType: { + type: String, + enum: ["driver", "admin", "investor", "system"], + required: true, + }, + actorId: { type: Schema.Types.ObjectId, ref: "User" }, + reason: { type: String, required: true, trim: true }, + correlationId: { type: String, trim: true, index: true }, + metadata: { type: Schema.Types.Mixed }, + timestamp: { type: Date, default: Date.now, index: true }, + }, + { + versionKey: false, + // History records are immutable once written; no updates allowed. + }, +) + +StateTransitionHistorySchema.index({ entityType: 1, entityId: 1, timestamp: -1 }) + +export default (mongoose.models.StateTransitionHistory || + mongoose.model( + "StateTransitionHistory", + StateTransitionHistorySchema, + )) as mongoose.Model diff --git a/models/Vehicle.ts b/models/Vehicle.ts index c2758838..7ba07344 100644 --- a/models/Vehicle.ts +++ b/models/Vehicle.ts @@ -26,6 +26,7 @@ export interface IVehicle extends Document { complianceStatus?: 'compliant' | 'warning' | 'non_compliant' | 'uninspected'; currentOdometerKm?: number; hasActiveDowntime?: boolean; + version: number; } const VehicleSchema: Schema = new Schema({ @@ -66,6 +67,7 @@ const VehicleSchema: Schema = new Schema({ }, currentOdometerKm: { type: Number, default: 0 }, hasActiveDowntime: { type: Boolean, default: false }, + version: { type: Number, default: 0 }, }); export default (mongoose.models.Vehicle || diff --git a/package-lock.json b/package-lock.json index e25235bd..03bce193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@stellar/stellar-sdk": "^14.6.1", "@tanstack/react-query": "^5.81.2", "@vercel/blob": "^2.3.3", + "@vitest/runner": "^4.1.10", "autoprefixer": "^10.4.20", "axios": "^1.15.0", "bcryptjs": "^3.0.2", @@ -74,6 +75,7 @@ "resend": "^4.0.0", "server-only": "^0.0.1", "sonner": "^1.7.1", + "std-env": "^3.8.0", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.6", @@ -10652,19 +10654,59 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@vitest/snapshot": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", @@ -13566,7 +13608,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, "license": "MIT" }, "node_modules/cookie-es": { @@ -20309,10 +20350,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -22357,6 +22397,20 @@ } } }, + "node_modules/vitest/node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 31e0846d..5a6c94b9 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@stellar/stellar-sdk": "^14.6.1", "@tanstack/react-query": "^5.81.2", "@vercel/blob": "^2.3.3", + "@vitest/runner": "^4.1.10", "autoprefixer": "^10.4.20", "axios": "^1.15.0", "bcryptjs": "^3.0.2", @@ -92,6 +93,7 @@ "resend": "^4.0.0", "server-only": "^0.0.1", "sonner": "^1.7.1", + "std-env": "^3.8.0", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.6", diff --git a/scripts/check-state-machine-integrity.ts b/scripts/check-state-machine-integrity.ts new file mode 100644 index 00000000..733bec6d --- /dev/null +++ b/scripts/check-state-machine-integrity.ts @@ -0,0 +1,299 @@ +/** + * Scans Loan, Vehicle, and Investment records for impossible state combinations + * and reports (or optionally repairs) them. + * + * Usage: + * tsx scripts/check-state-machine-integrity.ts # report only + * tsx scripts/check-state-machine-integrity.ts --repair # repair detected issues + */ +import "dotenv/config" +import mongoose from "mongoose" +import Loan from "@/models/Loan" +import Vehicle from "@/models/Vehicle" +import Investment from "@/models/Investment" + +const REPAIR = process.argv.includes("--repair") + +async function connect() { + const uri = process.env.MONGODB_URI + if (!uri) throw new Error("MONGODB_URI is not set.") + await mongoose.connect(uri) + console.log("Connected to MongoDB.") +} + +interface Finding { + entityType: string + entityId: string + issue: string + repairAction?: string +} + +const findings: Finding[] = [] + +function record(entityType: string, entityId: string, issue: string, repairAction?: string) { + findings.push({ entityType, entityId, issue, repairAction }) + console.warn(`[${entityType}] ${entityId}: ${issue}`) +} + +// ── Loan checks ────────────────────────────────────────────────────────────── + +async function checkLoans() { + console.log("\n── Checking Loans ──") + + // Active loans that never had a down payment + const activeLackingDownPayment = await Loan.find({ + status: "Active", + downPaymentMade: { $ne: true }, + }).select("_id status downPaymentMade requestedAmount totalFunded") + + for (const loan of activeLackingDownPayment) { + record( + "Loan", + loan._id.toString(), + `Active without downPaymentMade=true (downPaymentMade=${loan.downPaymentMade})`, + "Set status to 'Approved' pending manual verification", + ) + if (REPAIR) { + await Loan.updateOne({ _id: loan._id }, { $set: { status: "Approved" } }) + console.log(` ✓ Repaired: set to Approved`) + } + } + + // Active loans with insufficient funding + const activeLoanDocs = await Loan.find({ status: "Active" }).select( + "_id requestedAmount totalFunded", + ) + for (const loan of activeLoanDocs) { + if (Number(loan.totalFunded) < Number(loan.requestedAmount)) { + record( + "Loan", + loan._id.toString(), + `Active but totalFunded (${loan.totalFunded}) < requestedAmount (${loan.requestedAmount})`, + "Set status to 'Approved' pending manual funding verification", + ) + if (REPAIR) { + await Loan.updateOne({ _id: loan._id }, { $set: { status: "Approved" } }) + console.log(` ✓ Repaired: set to Approved`) + } + } + } + + // Completed loans whose vehicle is still Financed + const completedLoans = await Loan.find({ status: "Completed" }).select("_id vehicleId") + for (const loan of completedLoans) { + const vehicle = await Vehicle.findById(loan.vehicleId).select("_id status").lean() + if (vehicle && vehicle.status === "Financed") { + record( + "Loan", + loan._id.toString(), + `Completed loan but associated vehicle (${vehicle._id}) is still Financed`, + "Set vehicle to Available", + ) + if (REPAIR) { + await Vehicle.updateOne( + { _id: vehicle._id }, + { $set: { status: "Available", fundingStatus: "Open" }, $unset: { driverId: 1 } }, + ) + console.log(` ✓ Repaired vehicle ${vehicle._id}: set to Available`) + } + } + } + + // Rejected/Cancelled loans whose vehicle is still Reserved + const closedLoans = await Loan.find({ + status: { $in: ["Rejected", "Cancelled"] }, + }).select("_id vehicleId status") + for (const loan of closedLoans) { + const vehicle = await Vehicle.findById(loan.vehicleId).select("_id status").lean() + if (vehicle && vehicle.status === "Reserved") { + record( + "Loan", + loan._id.toString(), + `${loan.status} loan but associated vehicle (${vehicle._id}) is still Reserved`, + "Set vehicle to Available", + ) + if (REPAIR) { + await Vehicle.updateOne( + { _id: vehicle._id }, + { $set: { status: "Available" }, $unset: { driverId: 1 } }, + ) + console.log(` ✓ Repaired vehicle ${vehicle._id}: set to Available`) + } + } + } + + // Loans in impossible enum values (legacy 'Cancelled' before it was added) + const invalidStatusLoans = await Loan.find({ + status: { $nin: ["Pending", "Under Review", "Approved", "Rejected", "Active", "Completed", "Cancelled"] }, + }).select("_id status") + for (const loan of invalidStatusLoans) { + record("Loan", loan._id.toString(), `Unknown status value: '${loan.status}'`) + } + + console.log(` Checked ${completedLoans.length + closedLoans.length + activeLoanDocs.length} loans.`) +} + +// ── Vehicle checks ──────────────────────────────────────────────────────────── + +async function checkVehicles() { + console.log("\n── Checking Vehicles ──") + + // Financed vehicles with no active loan + const financedVehicles = await Vehicle.find({ status: "Financed" }).select("_id status driverId") + for (const vehicle of financedVehicles) { + const activeLoan = await Loan.findOne({ + vehicleId: vehicle._id, + status: "Active", + }) + .select("_id") + .lean() + if (!activeLoan) { + record( + "Vehicle", + vehicle._id.toString(), + "Financed with no matching Active loan", + "Set status to Available", + ) + if (REPAIR) { + await Vehicle.updateOne( + { _id: vehicle._id }, + { $set: { status: "Available" }, $unset: { driverId: 1 } }, + ) + console.log(` ✓ Repaired: set to Available`) + } + } + } + + // Reserved vehicles with no pending/review/approved loan + const reservedVehicles = await Vehicle.find({ status: "Reserved" }).select("_id status") + for (const vehicle of reservedVehicles) { + const pendingLoan = await Loan.findOne({ + vehicleId: vehicle._id, + status: { $in: ["Pending", "Under Review", "Approved"] }, + }) + .select("_id") + .lean() + if (!pendingLoan) { + record( + "Vehicle", + vehicle._id.toString(), + "Reserved with no matching in-progress loan", + "Set status to Available", + ) + if (REPAIR) { + await Vehicle.updateOne( + { _id: vehicle._id }, + { $set: { status: "Available" }, $unset: { driverId: 1 } }, + ) + console.log(` ✓ Repaired: set to Available`) + } + } + } + + // Retired vehicles that still have a driverId set + const retiredWithDriver = await Vehicle.find({ + status: "Retired", + driverId: { $exists: true }, + }).select("_id status driverId") + for (const vehicle of retiredWithDriver) { + record( + "Vehicle", + vehicle._id.toString(), + "Retired vehicle still has driverId set", + "Unset driverId", + ) + if (REPAIR) { + await Vehicle.updateOne({ _id: vehicle._id }, { $unset: { driverId: 1 } }) + console.log(` ✓ Repaired: unset driverId`) + } + } + + console.log( + ` Checked ${financedVehicles.length + reservedVehicles.length + retiredWithDriver.length} vehicles.`, + ) +} + +// ── Investment checks ───────────────────────────────────────────────────────── + +async function checkInvestments() { + console.log("\n── Checking Investments ──") + + // Active investments whose loan is Completed/Rejected/Cancelled + const activeInvestments = await Investment.find({ status: "Active" }).select( + "_id loanId vehicleId status", + ) + for (const inv of activeInvestments) { + if (inv.loanId) { + const loan = await Loan.findById(inv.loanId).select("_id status").lean() + if (loan && ["Completed", "Rejected", "Cancelled"].includes(loan.status)) { + record( + "Investment", + inv._id.toString(), + `Active but associated loan ${inv.loanId} is ${loan.status}`, + loan.status === "Completed" ? "Set to Completed" : "Set to Funding (loan never activated)", + ) + if (REPAIR) { + const targetStatus = loan.status === "Completed" ? "Completed" : "Funding" + await Investment.updateOne({ _id: inv._id }, { $set: { status: targetStatus } }) + console.log(` ✓ Repaired: set to ${targetStatus}`) + } + } + } + } + + // Investments with legacy 'Active' default that were created for unfunded loans + const fundingVehicleIds = await Vehicle.find({ fundingStatus: "Open" }).distinct("_id") + const mistakenActiveInvestments = await Investment.find({ + status: "Active", + vehicleId: { $in: fundingVehicleIds }, + }).select("_id vehicleId") + for (const inv of mistakenActiveInvestments) { + record( + "Investment", + inv._id.toString(), + `Status is Active but vehicle ${inv.vehicleId} fundingStatus is Open — likely pre-state-machine record`, + "Set to Funding", + ) + if (REPAIR) { + await Investment.updateOne({ _id: inv._id }, { $set: { status: "Funding" } }) + console.log(` ✓ Repaired: set to Funding`) + } + } + + console.log(` Checked ${activeInvestments.length + mistakenActiveInvestments.length} investments.`) +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +async function main() { + await connect() + console.log(`\nState machine integrity check — mode: ${REPAIR ? "REPAIR" : "REPORT ONLY"}\n`) + + await checkLoans() + await checkVehicles() + await checkInvestments() + + console.log(`\n────────────────────────────────────────`) + console.log(`Total findings: ${findings.length}`) + + if (findings.length === 0) { + console.log("✅ No integrity issues found.") + } else if (!REPAIR) { + console.log("⚠️ Run with --repair to fix the issues above.") + console.log("\nFindings summary:") + for (const f of findings) { + console.log(` [${f.entityType}] ${f.entityId}: ${f.issue}`) + if (f.repairAction) console.log(` → repair: ${f.repairAction}`) + } + process.exit(1) + } else { + console.log("✅ Repair completed. Review the changes above.") + } + + await mongoose.disconnect() +} + +main().catch((err) => { + console.error(err) + process.exit(1) +})