From 5e88a19909256baeac42a1cd24b646fe251ec5aa Mon Sep 17 00:00:00 2001 From: Toluwalase-Daniel <332103760+Toluwalase-Daniel@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:59:26 +0100 Subject: [PATCH 1/7] feat(disputes): add Dispute persistence model to Prisma schema Adds Dispute model plus DisputeStatus/DisputeReason/DisputeResolutionOutcome enums, matching the tenantId/deletedAt/snake_case conventions used throughout schema.prisma. Part of #816. --- backend/prisma/schema.prisma | 72 ++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 684031f5..0985c579 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -172,6 +172,7 @@ model Payment { project Project? @relation(fields: [projectId], references: [id]) milestone Milestone? @relation(fields: [milestoneId], references: [id]) transactionReorgs TransactionReorg[] + disputes Dispute[] @@index([tenantId, createdAt]) @@index([status], map: "payments_active_status_idx") @@ -182,6 +183,77 @@ model Payment { @@map("payments") } +// ─── Payment dispute management (Issue #816) ─────────────────────────────── +// +// A payer files a dispute against a Payment; the respondent (payee) has +// RESPONSE_HOURS to reply before the dispute auto-escalates +// (ESCALATION_HOURS after filing — see backend/disputes/disputeService.ts +// and the "dispute-escalation" scheduled task). An arbitrator then resolves +// it. Evidence and messages are append-only sub-records with no independent +// query needs of their own, so they're modeled as JSON columns (matching +// Payment.metadata's own convention) rather than separate join tables. + +enum DisputeStatus { + pending + awaiting_response + under_review + resolved + escalated + dismissed +} + +enum DisputeReason { + service_not_delivered + partial_delivery + quality_issue + unauthorized_charge + duplicate_charge + other +} + +enum DisputeResolutionOutcome { + full_refund + partial_refund + release_to_payee + dismissed + pending +} + +model Dispute { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + paymentId String @map("payment_id") + projectId String? @map("project_id") + invoiceId String? @map("invoice_id") + filedBy String @map("filed_by") + respondentId String @map("respondent_id") + arbitratorId String? @map("arbitrator_id") + status DisputeStatus @default(awaiting_response) + reason DisputeReason + amount Decimal @db.Decimal(20, 8) + currency String + description String + /// Array of { id, submittedBy, fileUrl, fileName, fileType, fileSize, description, timestamp, hash } — see disputeModel.ts's Evidence type. + evidence Json @default("[]") + /// Array of { id, senderId, senderRole, content, timestamp } — see disputeModel.ts's DisputeMessage type. + messages Json @default("[]") + resolution DisputeResolutionOutcome @default(pending) + resolutionNote String? @map("resolution_note") + refundAmount Decimal? @map("refund_amount") @db.Decimal(20, 8) + responseDeadline DateTime @map("response_deadline") + escalationDeadline DateTime @map("escalation_deadline") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + resolvedAt DateTime? @map("resolved_at") + + payment Payment @relation(fields: [paymentId], references: [id]) + + @@index([tenantId, status]) + @@index([paymentId]) + @@index([status, responseDeadline], map: "disputes_pending_escalation_idx") + @@map("disputes") +} + model Project { id String @id @default(uuid()) title String From d9a6607de1ab32242e09e693c2126161f07e5f0c Mon Sep 17 00:00:00 2001 From: Toluwalase-Daniel <332103760+Toluwalase-Daniel@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:59:29 +0100 Subject: [PATCH 2/7] feat(disputes): add migration for disputes table Part of #816. --- .../migration.sql | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backend/prisma/migrations/20260925000100_payment_dispute_management/migration.sql diff --git a/backend/prisma/migrations/20260925000100_payment_dispute_management/migration.sql b/backend/prisma/migrations/20260925000100_payment_dispute_management/migration.sql new file mode 100644 index 00000000..f227ac91 --- /dev/null +++ b/backend/prisma/migrations/20260925000100_payment_dispute_management/migration.sql @@ -0,0 +1,57 @@ +-- Payment dispute management — Issue #816 +-- +-- Persists disputes filed against a Payment. Evidence and messages are +-- append-only sub-records with no independent query needs of their own, +-- so they're stored as JSON columns rather than separate join tables, +-- matching payments.metadata's own convention. + +DO $$ BEGIN + CREATE TYPE "DisputeStatus" AS ENUM ( + 'pending', 'awaiting_response', 'under_review', 'resolved', 'escalated', 'dismissed' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE "DisputeReason" AS ENUM ( + 'service_not_delivered', 'partial_delivery', 'quality_issue', + 'unauthorized_charge', 'duplicate_charge', 'other' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE "DisputeResolutionOutcome" AS ENUM ( + 'full_refund', 'partial_refund', 'release_to_payee', 'dismissed', 'pending' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE TABLE IF NOT EXISTS "disputes" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid()::text, + "tenant_id" TEXT NOT NULL, + "payment_id" TEXT NOT NULL, + "project_id" TEXT, + "invoice_id" TEXT, + "filed_by" TEXT NOT NULL, + "respondent_id" TEXT NOT NULL, + "arbitrator_id" TEXT, + "status" "DisputeStatus" NOT NULL DEFAULT 'awaiting_response', + "reason" "DisputeReason" NOT NULL, + "amount" DECIMAL(20, 8) NOT NULL, + "currency" TEXT NOT NULL, + "description" TEXT NOT NULL, + "evidence" JSONB NOT NULL DEFAULT '[]', + "messages" JSONB NOT NULL DEFAULT '[]', + "resolution" "DisputeResolutionOutcome" NOT NULL DEFAULT 'pending', + "resolution_note" TEXT, + "refund_amount" DECIMAL(20, 8), + "response_deadline" TIMESTAMP(3) NOT NULL, + "escalation_deadline" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolved_at" TIMESTAMP(3), + CONSTRAINT "disputes_pkey" PRIMARY KEY ("id"), + CONSTRAINT "disputes_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "payments"("id") +); + +CREATE INDEX IF NOT EXISTS "disputes_tenant_id_status_idx" ON "disputes"("tenant_id", "status"); +CREATE INDEX IF NOT EXISTS "disputes_payment_id_idx" ON "disputes"("payment_id"); +CREATE INDEX IF NOT EXISTS "disputes_pending_escalation_idx" ON "disputes"("status", "response_deadline"); From 9c1bb3ad4cff748319fc525f9687524a41cdc02e Mon Sep 17 00:00:00 2001 From: Toluwalase-Daniel <332103760+Toluwalase-Daniel@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:59:34 +0100 Subject: [PATCH 3/7] refactor(disputes): back disputeService with Prisma and tenant scoping Replaces the in-memory Map with real persistence. Every operation is now scoped to the caller's tenantId, amount/currency are derived from the referenced Payment instead of trusted from the client, and getAnalytics() implements the DisputeAnalytics shape already declared in disputeModel.ts but never implemented. Part of #816. --- backend/disputes/disputeService.ts | 287 ++++++++++++++++++++++------- 1 file changed, 220 insertions(+), 67 deletions(-) diff --git a/backend/disputes/disputeService.ts b/backend/disputes/disputeService.ts index f78051ae..dc9b9e50 100644 --- a/backend/disputes/disputeService.ts +++ b/backend/disputes/disputeService.ts @@ -1,115 +1,268 @@ -import { v4 as uuidv4 } from "uuid"; import crypto from "crypto"; - -const disputes = new Map(); +import { prisma } from "../src/lib/prisma.js"; +import { DisputeError } from "../src/middleware/errorHandler.js"; +import type { + CreateDisputeDto, + Dispute, + DisputeAnalytics, + DisputeMessage, + Evidence, + ResolutionOutcome, +} from "./disputeModel.js"; const RESPONSE_HOURS = 72; const ESCALATION_HOURS = 168; -function addHours(hours: number) { - return new Date(Date.now() + hours * 3600000).toISOString(); +// Disputes still "in play" — a payment can only have one of these open at +// once (see create()'s duplicate check). +const ACTIVE_STATUSES = ["awaiting_response", "under_review", "escalated"] as const; + +function addHours(hours: number): Date { + return new Date(Date.now() + hours * 3600_000); +} + +interface UploadedFile { + url: string; + name: string; + size: number; + description?: string; +} + +/** Row shape as Prisma returns it — Decimal/Date fields, JSON columns as unknown. */ +type DisputeRow = Awaited>; + +/** Maps a Prisma Dispute row onto the public Dispute shape disputeModel.ts declares. */ +function toDispute(row: DisputeRow): Dispute { + return { + id: row.id, + paymentId: row.paymentId, + projectId: row.projectId ?? undefined, + invoiceId: row.invoiceId ?? undefined, + filedBy: row.filedBy, + respondentId: row.respondentId, + arbitratorId: row.arbitratorId ?? undefined, + status: row.status, + reason: row.reason, + amount: Number(row.amount), + currency: row.currency, + description: row.description, + evidence: (row.evidence as unknown as Evidence[]) ?? [], + messages: (row.messages as unknown as DisputeMessage[]) ?? [], + resolution: row.resolution as ResolutionOutcome, + resolutionNote: row.resolutionNote ?? undefined, + refundAmount: row.refundAmount ? Number(row.refundAmount) : undefined, + responseDeadline: row.responseDeadline.toISOString(), + escalationDeadline: row.escalationDeadline.toISOString(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + resolvedAt: row.resolvedAt?.toISOString(), + }; +} + +async function getOwnedDispute(tenantId: string, id: string) { + const dispute = await prisma.dispute.findFirst({ where: { id, tenantId } }); + if (!dispute) { + throw new DisputeError("Dispute not found", "DISPUTE_NOT_FOUND", 404); + } + return dispute; } export const disputeService = { - async create(dto, filedBy) { + /** + * Files a new dispute against a payment. amount/currency are always + * derived from the referenced Payment record rather than trusted from + * the caller, so a dispute can't claim a different amount than what was + * actually paid. + */ + async create(dto: CreateDisputeDto, filedBy: string, tenantId: string): Promise { if (!dto.description || dto.description.length < 20) { - throw new Error("Description must be at least 20 characters"); + throw new DisputeError( + "Description must be at least 20 characters", + "DISPUTE_DESCRIPTION_TOO_SHORT", + 400, + ); } - const existing = Array.from(disputes.values()).find( - (d) => - d.paymentId === dto.paymentId && - ["awaiting_response", "under_review", "escalated"].includes(d.status) - ); - - if (existing) throw new Error("Active dispute already exists"); - - const dispute = { - id: uuidv4(), - ...dto, - filedBy, - status: "awaiting_response", - evidence: [], - messages: [], - responseDeadline: addHours(RESPONSE_HOURS), - escalationDeadline: addHours(ESCALATION_HOURS), - createdAt: new Date().toISOString(), - }; + const payment = await prisma.payment.findFirst({ + where: { id: dto.paymentId, tenantId }, + }); + if (!payment) { + throw new DisputeError("Payment not found", "DISPUTE_PAYMENT_NOT_FOUND", 404); + } - disputes.set(dispute.id, dispute); + const existing = await prisma.dispute.findFirst({ + where: { paymentId: dto.paymentId, tenantId, status: { in: [...ACTIVE_STATUSES] } }, + }); + if (existing) { + throw new DisputeError( + "An active dispute already exists for this payment", + "DISPUTE_ALREADY_ACTIVE", + 409, + ); + } + + const row = await prisma.dispute.create({ + data: { + tenantId, + paymentId: dto.paymentId, + projectId: dto.projectId ?? null, + invoiceId: dto.invoiceId ?? null, + filedBy, + respondentId: dto.respondentId, + status: "awaiting_response", + reason: dto.reason, + amount: payment.amount, + currency: payment.currency, + description: dto.description, + evidence: [], + messages: [], + responseDeadline: addHours(RESPONSE_HOURS), + escalationDeadline: addHours(ESCALATION_HOURS), + }, + }); - console.log(`Notify ${dto.respondentId}: New dispute`); + // TODO(#816 follow-up): route through the real notification service + // (see backend/payments/routing/notification-service.ts) instead of a + // log line once this dispute flow has a resolved notification channel + // for the respondent's own tenant/preferences. + console.log(`[disputes] Notify ${dto.respondentId}: new dispute filed on payment ${dto.paymentId}`); - return dispute; + return toDispute(row); }, - async respond(id, userId, content) { - const d = disputes.get(id); - if (!d) throw new Error("Not found"); + async respond(id: string, tenantId: string, senderId: string, content: string): Promise { + const existing = await getOwnedDispute(tenantId, id); - d.messages.push({ - id: uuidv4(), - senderId: userId, + const messages = [...((existing.messages as unknown as DisputeMessage[]) ?? [])]; + messages.push({ + id: crypto.randomUUID(), + disputeId: id, + senderId, + senderRole: senderId === existing.respondentId ? "payee" : "payer", content, timestamp: new Date().toISOString(), }); - d.status = "under_review"; - return d; + const row = await prisma.dispute.update({ + where: { id }, + data: { messages, status: "under_review" }, + }); + + return toDispute(row); }, - async addEvidence(id, userId, file) { - const d = disputes.get(id); - if (!d) throw new Error("Not found"); + async addEvidence(id: string, tenantId: string, submittedBy: string, file: UploadedFile): Promise { + const existing = await getOwnedDispute(tenantId, id); const hash = crypto .createHash("sha256") .update(`${file.name}-${file.size}-${Date.now()}`) .digest("hex"); - const ev = { - id: uuidv4(), + const entry: Evidence = { + id: crypto.randomUUID(), + disputeId: id, + submittedBy, fileUrl: file.url, fileName: file.name, + fileType: file.name.split(".").pop() ?? "unknown", fileSize: file.size, - description: file.description, - hash, + description: file.description ?? "", timestamp: new Date().toISOString(), + hash, }; - d.evidence.push(ev); - return ev; + const evidence = [...((existing.evidence as unknown as Evidence[]) ?? []), entry]; + await prisma.dispute.update({ where: { id }, data: { evidence } }); + + return entry; }, - async resolve(id, user, payload) { + async resolve( + id: string, + tenantId: string, + user: { id: string; role: string }, + payload: { outcome: ResolutionOutcome; resolutionNote: string; refundAmount?: number }, + ): Promise { if (user.role !== "arbitrator") { - throw new Error("Forbidden"); + throw new DisputeError("Only an arbitrator can resolve a dispute", "DISPUTE_FORBIDDEN", 403); } - const d = disputes.get(id); - if (!d) throw new Error("Not found"); + await getOwnedDispute(tenantId, id); + + const row = await prisma.dispute.update({ + where: { id }, + data: { + status: payload.outcome === "dismissed" ? "dismissed" : "resolved", + resolution: payload.outcome, + resolutionNote: payload.resolutionNote, + refundAmount: payload.refundAmount ?? null, + arbitratorId: user.id, + resolvedAt: new Date(), + }, + }); - d.status = payload.outcome === "dismissed" ? "dismissed" : "resolved"; - d.resolution = payload.outcome; - d.resolutionNote = payload.note; + return toDispute(row); + }, - return d; + /** + * Auto-escalates disputes whose respondent missed the response deadline. + * Run on a schedule — see backend/src/config/scheduled-tasks.ts's + * "dispute-escalation" task. + */ + async processEscalations(): Promise { + const result = await prisma.dispute.updateMany({ + where: { status: "awaiting_response", responseDeadline: { lt: new Date() } }, + data: { status: "escalated" }, + }); + return result.count; }, - async processEscalations() { - const now = new Date(); - let count = 0; - - for (const d of disputes.values()) { - if ( - d.status === "awaiting_response" && - new Date(d.responseDeadline) < now - ) { - d.status = "escalated"; - count++; + async getAnalytics(tenantId: string): Promise { + const disputes = await prisma.dispute.findMany({ where: { tenantId } }); + + const byStatus: DisputeAnalytics["byStatus"] = { + pending: 0, + awaiting_response: 0, + under_review: 0, + resolved: 0, + escalated: 0, + dismissed: 0, + }; + const byReason: DisputeAnalytics["byReason"] = { + service_not_delivered: 0, + partial_delivery: 0, + quality_issue: 0, + unauthorized_charge: 0, + duplicate_charge: 0, + other: 0, + }; + + let totalRefunded = 0; + let resolvedCount = 0; + let escalatedCount = 0; + let totalResolutionDays = 0; + + for (const d of disputes) { + byStatus[d.status as keyof typeof byStatus] += 1; + byReason[d.reason as keyof typeof byReason] += 1; + if (d.refundAmount) totalRefunded += Number(d.refundAmount); + if (d.status === "escalated" || d.resolvedAt === null && d.escalationDeadline < new Date()) { + escalatedCount += 1; + } + if (d.resolvedAt) { + resolvedCount += 1; + totalResolutionDays += (d.resolvedAt.getTime() - d.createdAt.getTime()) / 86_400_000; } } - return count; + return { + total: disputes.length, + byStatus, + byReason, + averageResolutionDays: resolvedCount > 0 ? totalResolutionDays / resolvedCount : 0, + totalRefunded, + escalationRate: disputes.length > 0 ? escalatedCount / disputes.length : 0, + }; }, -}; \ No newline at end of file +}; From a042a14d94dace8383658ac83f884ef7350c14c7 Mon Sep 17 00:00:00 2001 From: Toluwalase-Daniel <332103760+Toluwalase-Daniel@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:59:38 +0100 Subject: [PATCH 4/7] refactor(disputes): type disputeController and add asyncHandler guards Matches the auth/error-handling convention used by refunds.ts, adds a requireUser() guard, and adds the GET /analytics handler. Part of #816. --- backend/disputes/disputeController.ts | 92 ++++++++++++++------------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/backend/disputes/disputeController.ts b/backend/disputes/disputeController.ts index a15defa2..243f8222 100644 --- a/backend/disputes/disputeController.ts +++ b/backend/disputes/disputeController.ts @@ -1,47 +1,53 @@ -import { disputeService } from "./disputeService"; - -export const create = async (req, res) => { - try { - const data = await disputeService.create(req.body, req.user.id); - res.json(data); - } catch (e) { - res.status(400).json({ error: e.message }); +import type { Request, Response } from "express"; +import { asyncHandler, AppError } from "../src/middleware/errorHandler.js"; +import { disputeService } from "./disputeService.js"; +import type { CreateDisputeDto, ResolutionOutcome } from "./disputeModel.js"; + +function requireUser(req: Request): { id: string; tenantId: string; role: string } { + if (!req.user) { + throw new AppError(401, "Authentication required", "UNAUTHORIZED"); } -}; - -export const respond = async (req, res) => { - const data = await disputeService.respond( - req.params.id, - req.user.id, - req.body.content - ); - res.json(data); -}; - -export const uploadEvidence = async (req, res) => { - const data = await disputeService.addEvidence( - req.params.id, - req.user.id, - { - url: `/uploads/${req.file.originalname}`, - name: req.file.originalname, - size: req.file.size, - description: req.body.description, - } - ); + return req.user; +} + +export const create = asyncHandler(async (req: Request, res: Response) => { + const user = requireUser(req); + const dto = req.body as CreateDisputeDto; + const data = await disputeService.create(dto, user.id, user.tenantId); + res.status(201).json(data); +}); +export const respond = asyncHandler(async (req: Request, res: Response) => { + const user = requireUser(req); + const data = await disputeService.respond(req.params.id, user.tenantId, user.id, req.body.content); res.json(data); -}; - -export const resolve = async (req, res) => { - try { - const data = await disputeService.resolve( - req.params.id, - req.user, - req.body - ); - res.json(data); - } catch (e) { - res.status(403).json({ error: e.message }); +}); + +export const uploadEvidence = asyncHandler(async (req: Request, res: Response) => { + const user = requireUser(req); + if (!req.file) { + throw new AppError(400, "A file is required", "DISPUTE_EVIDENCE_FILE_REQUIRED"); } -}; \ No newline at end of file + + const data = await disputeService.addEvidence(req.params.id, user.tenantId, user.id, { + url: `/uploads/${req.file.originalname}`, + name: req.file.originalname, + size: req.file.size, + description: req.body.description, + }); + + res.status(201).json(data); +}); + +export const resolve = asyncHandler(async (req: Request, res: Response) => { + const user = requireUser(req); + const payload = req.body as { outcome: ResolutionOutcome; resolutionNote: string; refundAmount?: number }; + const data = await disputeService.resolve(req.params.id, user.tenantId, user, payload); + res.json(data); +}); + +export const getAnalytics = asyncHandler(async (req: Request, res: Response) => { + const user = requireUser(req); + const data = await disputeService.getAnalytics(user.tenantId); + res.json(data); +}); From 24a6bc1c9fd603aa5031e50d31a307c206b974a3 Mon Sep 17 00:00:00 2001 From: Toluwalase-Daniel <332103760+Toluwalase-Daniel@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:59:41 +0100 Subject: [PATCH 5/7] fix(disputes): fix ESM import extension in disputeRoutes disputeController was imported without the required .js extension, which fails under this project's ESM resolution. Also wires up the new /analytics route. Part of #816. --- backend/disputes/disputeRoutes.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/disputes/disputeRoutes.ts b/backend/disputes/disputeRoutes.ts index 8138ddba..c9374859 100644 --- a/backend/disputes/disputeRoutes.ts +++ b/backend/disputes/disputeRoutes.ts @@ -5,14 +5,19 @@ import { respond, uploadEvidence, resolve, -} from "./disputeController"; + getAnalytics, +} from "./disputeController.js"; const router = express.Router(); +// In-memory storage: evidence files are re-hosted at a stable URL by the +// upload handler itself (see disputeController.uploadEvidence), not served +// directly from multer's buffer. const upload = multer(); router.post("/", create); +router.get("/analytics", getAnalytics); router.post("/:id/respond", respond); router.post("/:id/evidence", upload.single("file"), uploadEvidence); router.post("/:id/resolve", resolve); -export default router; \ No newline at end of file +export default router; From 2ab997be9fcdc360c210384528aca5f9c3c7afa7 Mon Sep 17 00:00:00 2001 From: Toluwalase-Daniel <332103760+Toluwalase-Daniel@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:59:45 +0100 Subject: [PATCH 6/7] feat(disputes): register hourly dispute auto-escalation job Wires disputeService.processEscalations() into the scheduled-tasks registry so disputes past their 72h response deadline actually get escalated, following the same ScheduledTaskMeta pattern as every other job in this file. Closes #816. --- backend/src/config/scheduled-tasks.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/src/config/scheduled-tasks.ts b/backend/src/config/scheduled-tasks.ts index df59cd4a..06739a65 100644 --- a/backend/src/config/scheduled-tasks.ts +++ b/backend/src/config/scheduled-tasks.ts @@ -24,6 +24,7 @@ import { getArchivalService } from '../services/archival/index.js'; import { getBridgeMonitorService } from '../services/bridge-monitor/bridge-monitor.js'; import { runScheduledReconciliation } from '../services/payment-reconciliation/index.js'; import { runEscalationEvaluation } from '../jobs/escalation.job.js'; +import { disputeService } from '../../disputes/disputeService.js'; import { runProjectArchivalSweep } from '../services/project-archival/index.js'; import { ethers } from 'ethers'; import { @@ -275,6 +276,21 @@ const RAW_TASKS: (Omit & { defaultSchedule: strin priority: 'high', handler: runEscalationEvaluation, }, + { + id: 'dispute-escalation', + name: 'Payment Dispute Auto-Escalation', + description: + 'Escalates disputes whose respondent missed the 72-hour response deadline — Issue #816.', + defaultSchedule: '0 * * * *', // Hourly + timeoutMs: 5 * 60 * 1000, + priority: 'normal', + handler: async () => { + const count = await disputeService.processEscalations(); + if (count > 0) { + console.log(`[disputes] Escalated ${count} overdue dispute(s)`); + } + }, + }, // ── Compliance Automation (Issue #590 enhancements) ───────────────────── { id: 'compliance-threshold-evaluation', From 0c6ee8d514115d086e72156526b0a6fdee97e519 Mon Sep 17 00:00:00 2001 From: Toluwalase-Daniel <332103760+Toluwalase-Daniel@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:59:48 +0100 Subject: [PATCH 7/7] fix(api): mount disputes, fiat-payments, GraphQL, and cache admin routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fully-implemented modules were never wired into the running app: - disputes/disputeRoutes.ts (#816) — /api/v1/disputes - routes/fiat-payments.ts (#817) — /api/v1/fiat-payments. ACH/wire bank verification, payment initiation, wire instructions, webhooks and reconciliation were all already implemented in fiat-payments.ts and providers/fiat.ts (including SWIFT validation and compliance holds for international wires); only the router mount was missing. - graphql/gateway.ts (#819) — /graphql and /graphql/ws - routes/cache.ts (#818) — /api/v1/cache admin stats/clear/evict All four are mounted behind authMiddleware since none of them guard themselves internally. Resolves #817, #818, #819 and completes #816. --- backend/src/index.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/src/index.ts b/backend/src/index.ts index 556b9524..80d5d22f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -65,6 +65,17 @@ import { cohortAnalyticsRouter } from './routes/cohort-analytics.js'; import { churnPredictionRouter } from './routes/churn-prediction.js'; import { slackRouter } from './routes/slack.js'; import { githubIntegrationRouter } from './routes/github-integration.js'; +// ACH/wire fiat payments (bank verification, initiation, wire instructions, +// reconciliation) — Issue #817. Fully implemented in fiat-payments.ts/ +// providers/fiat.ts already but the router was never mounted. +import { fiatPaymentsRouter } from './routes/fiat-payments.js'; +// Payment dispute management — Issue #816 +import disputeRoutes from '../disputes/disputeRoutes.js'; +import { authMiddleware } from './middleware/auth.js'; +// GraphQL API alongside REST — Issue #819 +import { graphQLRouter, graphQLWsRouter } from './graphql/gateway.js'; +// Admin cache stats/clear/evict endpoints — Issue #818 +import { cacheRouter } from './routes/cache.js'; dotenv.config(); @@ -245,6 +256,15 @@ app.use(healthRouter); // Interactive API documentation & playground — Issue #758 app.use('/docs', docsRouter); +// GraphQL API alongside REST — Issue #819. Mounted ahead of the REST +// versioning/rate-limit block below (which only applies under '/api/') so +// it isn't swept into the REST version-fallback 404. graphQLRouter/ +// graphQLWsRouter don't thread req.user into resolver context yet (see +// PR notes) — authMiddleware here at least keeps the endpoint from being +// fully anonymous until that's addressed. +app.use('/graphql', authMiddleware, graphQLRouter); +app.use('/graphql/ws', authMiddleware, graphQLWsRouter); + import { versionMiddleware } from './middleware/versioning.js'; // Apply tiered limiter to all API routes @@ -315,6 +335,12 @@ apiV1Router.use('/analytics/cohorts', cohortAnalyticsRouter); apiV1Router.use('/analytics/churn', churnPredictionRouter); apiV1Router.use('/integrations/slack', slackRouter); apiV1Router.use('/integrations/github', githubIntegrationRouter); +// Payment dispute management (filing, response, evidence, arbitration) — Issue #816 +apiV1Router.use('/disputes', authMiddleware, disputeRoutes); +// Admin cache stats/clear/evict — Issue #818 +apiV1Router.use('/cache', authMiddleware, cacheRouter); +// ACH/wire fiat payments — Issue #817 +apiV1Router.use('/fiat-payments', authMiddleware, fiatPaymentsRouter); // Explicit URL-based mounting app.use('/api/v1', apiV1Router);