diff --git a/migrations/0019_disputes.sql b/migrations/0019_disputes.sql index fbf9c8bc..802e7940 100644 --- a/migrations/0019_disputes.sql +++ b/migrations/0019_disputes.sql @@ -2,36 +2,37 @@ -- Tracks per-developer billing disputes with a simple state machine. -- States: OPEN → REFUNDED (admin) | OPEN → UPHELD (admin) -CREATE TABLE IF NOT EXISTS `disputes` ( - `id` text PRIMARY KEY NOT NULL, - `usage_event_id` text NOT NULL, - `opened_by` text NOT NULL, -- developer user_id - `reason` text NOT NULL, - `status` text NOT NULL DEFAULT 'OPEN', - `created_at` text NOT NULL DEFAULT (datetime('now')), - `resolved_at` text, - `resolved_by` text, - CHECK (`status` IN ('OPEN', 'REFUNDED', 'UPHELD')) +CREATE TABLE IF NOT EXISTS disputes ( + id TEXT PRIMARY KEY NOT NULL, + usage_event_id TEXT NOT NULL UNIQUE, + opened_by TEXT NOT NULL, + reason TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'OPEN', + resolution_notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at TEXT, + resolved_by TEXT, + CHECK (status IN ('OPEN', 'REFUNDED', 'UPHELD')) ); --- Enforce: only one non-resolved dispute per usage_event_id -CREATE UNIQUE INDEX IF NOT EXISTS `idx_disputes_usage_event_open` - ON `disputes` (`usage_event_id`) - WHERE `status` = 'OPEN'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_disputes_usage_event_open + ON disputes (usage_event_id); -CREATE INDEX IF NOT EXISTS `idx_disputes_opened_by` ON `disputes` (`opened_by`); -CREATE INDEX IF NOT EXISTS `idx_disputes_usage_event_id` ON `disputes` (`usage_event_id`); -CREATE INDEX IF NOT EXISTS `idx_disputes_status` ON `disputes` (`status`); +CREATE INDEX IF NOT EXISTS idx_disputes_opened_by ON disputes (opened_by); +CREATE INDEX IF NOT EXISTS idx_disputes_usage_event_id ON disputes (usage_event_id); +CREATE INDEX IF NOT EXISTS idx_disputes_status ON disputes (status); -- Create dispute_events audit trail table -CREATE TABLE IF NOT EXISTS `dispute_events` ( - `id` text PRIMARY KEY NOT NULL, - `dispute_id` text NOT NULL, - `actor` text NOT NULL, - `action` text NOT NULL, - `details` text, -- JSON-encoded optional metadata - `created_at` text NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (`dispute_id`) REFERENCES `disputes`(`id`) ON DELETE CASCADE +CREATE TABLE IF NOT EXISTS dispute_events ( + id TEXT PRIMARY KEY NOT NULL, + dispute_id TEXT NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + notes TEXT, + details TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (dispute_id) REFERENCES disputes(id) ON DELETE CASCADE, + CHECK (action IN ('OPENED', 'RESOLVED', 'NOTE_ADDED')) ); -CREATE INDEX IF NOT EXISTS `idx_dispute_events_dispute_id` ON `dispute_events` (`dispute_id`); +CREATE INDEX IF NOT EXISTS idx_dispute_events_dispute_id ON dispute_events (dispute_id); diff --git a/src/app.ts b/src/app.ts index 47eb6658..ac32a433 100644 --- a/src/app.ts +++ b/src/app.ts @@ -111,11 +111,20 @@ import { } from "./middleware/envelope.js"; import { openApiErrorHandler } from "./middleware/openApiErrorHandler.js"; //import * as OpenApiValidator from 'express-openapi-validator'; +import { createDisputesRouter } from "./routes/billing/disputes.js"; +import { + DisputeService, + InMemoryDisputeRepository, + PgDisputeRepository, + type DisputeRepository, +} from "./services/disputeService.js"; interface AppDependencies extends SorobanBillingDependencies { usageEventsRepository?: UsageEventsRepository; healthCheckConfig?: HealthCheckConfig; vaultRepository?: VaultRepository; + disputeRepository?: DisputeRepository; + disputeService?: DisputeService; apiRepository?: ApiRepository; developerRepository?: DeveloperRepository; findDeveloperByUserId?: (userId: string) => Promise; @@ -454,6 +463,16 @@ export const createApp = (dependencies?: Partial) => { // Webhook management routes app.use('/api/webhooks', createWebhooksRouter()); + // Billing disputes — backed by PgDisputeRepository against pool in production/dev + const disputeRepository: DisputeRepository = + dependencies?.disputeRepository ?? + (process.env.NODE_ENV === "test" + ? new InMemoryDisputeRepository() + : new PgDisputeRepository(pool)); + const disputeService = + dependencies?.disputeService ?? new DisputeService(disputeRepository); + app.use("/api/billing/disputes", createDisputesRouter({ disputeService })); + // Mount all routes including billing and limits app.use( "/api", diff --git a/src/routes/billing/disputes.test.ts b/src/routes/billing/disputes.test.ts index 5b6afde3..e68339e9 100644 --- a/src/routes/billing/disputes.test.ts +++ b/src/routes/billing/disputes.test.ts @@ -1,10 +1,11 @@ /** - * Tests for dispute-resolution flow (#463). + * Tests for dispute-resolution flow (#463, #1243). * * Covers: * - openDisputeSchema / resolveDisputeSchema validation * - InMemoryDisputeRepository state machine + audit trail - * - DisputeService RBAC helpers + * - PgDisputeRepository Postgres persistence (pg-mem against migrations/0019_disputes.sql) + * - DisputeService RBAC helpers (sync and async repositories) * - HTTP endpoints: open, list, get, resolve, admin list * - Auth enforcement on every route */ @@ -17,15 +18,21 @@ jest.mock('better-sqlite3', () => { }; }); +import fs from 'node:fs'; +import path from 'node:path'; import express from 'express'; -import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { newDb } from 'pg-mem'; +import supertest from 'supertest'; import { errorHandler } from '../../middleware/errorHandler.js'; import { createDisputesRouter } from './disputes.js'; import { openDisputeSchema, resolveDisputeSchema, InMemoryDisputeRepository, + PgDisputeRepository, DisputeService, + type DisputeDb, } from '../../services/disputeService.js'; // --------------------------------------------------------------------------- @@ -33,6 +40,37 @@ import { // --------------------------------------------------------------------------- const ADMIN_KEY = 'test-admin-key'; +const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-for-disputes'; +process.env.JWT_SECRET = JWT_SECRET; + +function signUserToken(userId: string): string { + return jwt.sign({ userId, sub: userId }, JWT_SECRET, { algorithm: 'HS256', expiresIn: '1h' }); +} + +function wrapAgent(agent: ReturnType) { + const methods = ['get', 'post', 'put', 'patch', 'delete'] as const; + return new Proxy(agent, { + get(target, prop, receiver) { + if (methods.includes(prop as (typeof methods)[number])) { + const orig = Reflect.get(target, prop, receiver).bind(target); + return (...args: unknown[]) => { + const req = orig(...args); + const origSet = req.set.bind(req); + req.set = (field: string | Record, val?: string) => { + if (typeof field === 'string' && field.toLowerCase() === 'x-user-id' && typeof val === 'string') { + return origSet('Authorization', `Bearer ${signUserToken(val)}`); + } + return origSet(field, val as string); + }; + return req; + }; + } + return Reflect.get(target, prop, receiver); + }, + }); +} + +const request = ((app: express.Express) => wrapAgent(supertest(app))) as unknown as typeof supertest; function buildApp(svc?: DisputeService) { const app = express(); @@ -47,6 +85,16 @@ function makeSvc() { return { svc: new DisputeService(repo), repo }; } +function createPgMemDisputeDb(): DisputeDb { + const mem = newDb(); + const migrationPath = path.resolve(process.cwd(), 'migrations/0019_disputes.sql'); + const sql = fs.readFileSync(migrationPath, 'utf8'); + mem.public.none(sql); + const pgAdapter = mem.adapters.createPg(); + const pool = new pgAdapter.Pool(); + return pool as unknown as DisputeDb; +} + // --------------------------------------------------------------------------- // Schema validation // --------------------------------------------------------------------------- @@ -154,6 +202,93 @@ describe('InMemoryDisputeRepository', () => { }); }); +// --------------------------------------------------------------------------- +// PgDisputeRepository (Postgres contract tests against 0019_disputes.sql) +// --------------------------------------------------------------------------- + +describe('PgDisputeRepository (Postgres persistence)', () => { + let repo: PgDisputeRepository; + let svc: DisputeService; + + beforeEach(() => { + const db = createPgMemDisputeDb(); + repo = new PgDisputeRepository(db); + svc = new DisputeService(repo); + }); + + it('creates a dispute in OPEN state and retrieves it by id', async () => { + const created = await repo.create({ usage_event_id: 'pg-evt-1', reason: 'double deduction' }, 'dev-1'); + expect(created.status).toBe('OPEN'); + expect(created.opened_by).toBe('dev-1'); + expect(created.usage_event_id).toBe('pg-evt-1'); + expect(created.resolved_at).toBeNull(); + + const fetched = await repo.findById(created.id); + expect(fetched).toEqual(created); + }); + + it('throws ConflictError on duplicate usage_event_id', async () => { + await repo.create({ usage_event_id: 'pg-evt-dup', reason: 'first' }, 'dev-1'); + await expect( + repo.create({ usage_event_id: 'pg-evt-dup', reason: 'second' }, 'dev-2'), + ).rejects.toThrow(/already exists/); + }); + + it('findByUser and listAll return persisted disputes', async () => { + await repo.create({ usage_event_id: 'pg-e1', reason: 'r1' }, 'dev-1'); + await repo.create({ usage_event_id: 'pg-e2', reason: 'r2' }, 'dev-2'); + + const dev1Disputes = await repo.findByUser('dev-1'); + expect(dev1Disputes).toHaveLength(1); + expect(dev1Disputes[0].opened_by).toBe('dev-1'); + + const all = await repo.listAll(); + expect(all).toHaveLength(2); + }); + + it('transitions OPEN → REFUNDED and persists resolution_notes', async () => { + const created = await repo.create({ usage_event_id: 'pg-e-ref', reason: 'timeout' }, 'dev-1'); + const resolved = await repo.resolve(created.id, 'REFUNDED', 'admin-user', 'refund approved'); + expect(resolved.status).toBe('REFUNDED'); + expect(resolved.resolved_by).toBe('admin-user'); + expect(resolved.resolution_notes).toBe('refund approved'); + expect(resolved.resolved_at).not.toBeNull(); + + await expect(repo.resolve(created.id, 'UPHELD', 'admin-user')).rejects.toThrow(/already/); + }); + + it('persists dispute_events audit trail end-to-end through DisputeService and HTTP routes', async () => { + process.env.ADMIN_API_KEY = ADMIN_KEY; + const app = buildApp(svc); + + const openRes = await request(app) + .post('/api/billing/disputes') + .set('x-user-id', 'dev-pg-1') + .send({ usage_event_id: 'pg-http-evt-1', reason: 'upstream 502 charged' }); + expect(openRes.status).toBe(201); + expect(openRes.body.status).toBe('OPEN'); + + const disputeId = openRes.body.id as string; + + const resolveRes = await request(app) + .post(`/api/billing/disputes/${disputeId}/resolve`) + .set('x-admin-api-key', ADMIN_KEY) + .send({ resolution: 'REFUNDED', notes: 'verified upstream 502' }); + expect(resolveRes.status).toBe(200); + expect(resolveRes.body.status).toBe('REFUNDED'); + expect(resolveRes.body.resolution_notes).toBe('verified upstream 502'); + + const detailRes = await request(app) + .get(`/api/billing/disputes/${disputeId}`) + .set('x-user-id', 'dev-pg-1'); + expect(detailRes.status).toBe(200); + expect(detailRes.body.dispute.status).toBe('REFUNDED'); + expect(detailRes.body.events).toHaveLength(2); + expect(detailRes.body.events[0].action).toBe('OPENED'); + expect(detailRes.body.events[1].action).toBe('RESOLVED'); + }); +}); + // --------------------------------------------------------------------------- // DisputeService // --------------------------------------------------------------------------- @@ -161,22 +296,22 @@ describe('InMemoryDisputeRepository', () => { describe('DisputeService', () => { it('openDispute creates dispute and appends OPENED event', () => { const { svc, repo } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; expect(d.status).toBe('OPEN'); expect(repo.getEvents(d.id).some(e => e.action === 'OPENED')).toBe(true); }); it('resolveDispute updates status and appends RESOLVED event', () => { const { svc, repo } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); - const resolved = svc.resolveDispute(d.id, { resolution: 'REFUNDED' }, 'admin'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; + const resolved = svc.resolveDispute(d.id, { resolution: 'REFUNDED' }, 'admin') as ReturnType; expect(resolved.status).toBe('REFUNDED'); expect(repo.getEvents(d.id).some(e => e.action === 'RESOLVED')).toBe(true); }); it('getDisputeForDeveloper throws ForbiddenError for wrong user', () => { const { svc } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; expect(() => svc.getDisputeForDeveloper(d.id, 'u2')).toThrow(/do not have access/); }); @@ -248,14 +383,14 @@ describe('GET /api/billing/disputes', () => { describe('GET /api/billing/disputes/:id', () => { it('returns 401 without auth', async () => { const { svc } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; const res = await request(buildApp(svc)).get(`/api/billing/disputes/${d.id}`); expect(res.status).toBe(401); }); it('returns 403 when another user accesses the dispute', async () => { const { svc } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; const res = await request(buildApp(svc)) .get(`/api/billing/disputes/${d.id}`) .set('x-user-id', 'u2'); @@ -264,7 +399,7 @@ describe('GET /api/billing/disputes/:id', () => { it('returns dispute + events for the owner', async () => { const { svc } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; const res = await request(buildApp(svc)) .get(`/api/billing/disputes/${d.id}`) .set('x-user-id', 'u1'); @@ -284,7 +419,7 @@ describe('GET /api/billing/disputes/:id', () => { describe('POST /api/billing/disputes/:id/resolve', () => { it('returns 401 without admin auth', async () => { const { svc } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; const res = await request(buildApp(svc)) .post(`/api/billing/disputes/${d.id}/resolve`) .send({ resolution: 'REFUNDED' }); @@ -294,7 +429,7 @@ describe('POST /api/billing/disputes/:id/resolve', () => { it('resolves a dispute as admin (REFUNDED)', async () => { process.env.ADMIN_API_KEY = ADMIN_KEY; const { svc } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; const res = await request(buildApp(svc)) .post(`/api/billing/disputes/${d.id}/resolve`) .set('x-admin-api-key', ADMIN_KEY) @@ -306,7 +441,7 @@ describe('POST /api/billing/disputes/:id/resolve', () => { it('resolves a dispute as admin (UPHELD)', async () => { process.env.ADMIN_API_KEY = ADMIN_KEY; const { svc } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; const res = await request(buildApp(svc)) .post(`/api/billing/disputes/${d.id}/resolve`) .set('x-admin-api-key', ADMIN_KEY) @@ -318,7 +453,7 @@ describe('POST /api/billing/disputes/:id/resolve', () => { it('returns 400 for invalid resolution value', async () => { process.env.ADMIN_API_KEY = ADMIN_KEY; const { svc } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; const res = await request(buildApp(svc)) .post(`/api/billing/disputes/${d.id}/resolve`) .set('x-admin-api-key', ADMIN_KEY) @@ -338,7 +473,7 @@ describe('POST /api/billing/disputes/:id/resolve', () => { it('returns 409 when already resolved', async () => { process.env.ADMIN_API_KEY = ADMIN_KEY; const { svc } = makeSvc(); - const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1'); + const d = svc.openDispute({ usage_event_id: 'e1', reason: 'x' }, 'u1') as ReturnType; svc.resolveDispute(d.id, { resolution: 'UPHELD' }, 'admin'); const res = await request(buildApp(svc)) .post(`/api/billing/disputes/${d.id}/resolve`) diff --git a/src/routes/billing/disputes.ts b/src/routes/billing/disputes.ts index d2d5b47f..2a9a1481 100644 --- a/src/routes/billing/disputes.ts +++ b/src/routes/billing/disputes.ts @@ -37,11 +37,11 @@ export function createDisputesRouter(deps: DisputesRouterDeps = {}): Router { '/', requireAuth, bodyValidator(openDisputeSchema), - (req, res: Response, next) => { + async (req, res: Response, next) => { try { const actor = res.locals.authenticatedUser!.id; const input = openDisputeSchema.parse(req.body); - const dispute = svc.openDispute(input, actor); + const dispute = await svc.openDispute(input, actor); logger.audit('DISPUTE_OPENED', actor, { disputeId: dispute.id, @@ -59,10 +59,10 @@ export function createDisputesRouter(deps: DisputesRouterDeps = {}): Router { router.get( '/', requireAuth, - (req, res: Response, next) => { + async (req, res: Response, next) => { try { const actor = res.locals.authenticatedUser!.id; - const disputes = svc.listForDeveloper(actor); + const disputes = await svc.listForDeveloper(actor); res.json({ disputes, total: disputes.length }); } catch (err) { next(err); @@ -72,9 +72,9 @@ export function createDisputesRouter(deps: DisputesRouterDeps = {}): Router { // ── GET /admin/all — admin lists all disputes ─────────────────────────── // Registered before /:id so 'admin' is not treated as a dispute id - router.get('/admin/all', adminAuth, (_req, res, next) => { + router.get('/admin/all', adminAuth, async (_req, res, next) => { try { - const disputes = svc.listAll(); + const disputes = await svc.listAll(); res.json({ disputes, total: disputes.length }); } catch (err) { next(err); @@ -85,11 +85,11 @@ export function createDisputesRouter(deps: DisputesRouterDeps = {}): Router { router.get( '/:id', requireAuth, - (req, res: Response, next) => { + async (req, res: Response, next) => { try { const actor = res.locals.authenticatedUser!.id; - const dispute = svc.getDisputeForDeveloper(req.params.id, actor); - const events = svc.getEvents(dispute.id); + const dispute = await svc.getDisputeForDeveloper(req.params.id, actor); + const events = await svc.getEvents(dispute.id); res.json({ dispute, events }); } catch (err) { next(err); @@ -102,7 +102,7 @@ export function createDisputesRouter(deps: DisputesRouterDeps = {}): Router { '/:id/resolve', adminAuth, bodyValidator(resolveDisputeSchema), - (req, res, next) => { + async (req, res, next) => { try { const adminActor = (res.locals as { adminActor?: string }).adminActor; if (!adminActor) { @@ -111,7 +111,7 @@ export function createDisputesRouter(deps: DisputesRouterDeps = {}): Router { } const input = resolveDisputeSchema.parse(req.body); - const dispute = svc.resolveDispute(req.params.id, input, adminActor); + const dispute = await svc.resolveDispute(req.params.id, input, adminActor); logger.audit('DISPUTE_RESOLVED', adminActor, { disputeId: dispute.id, diff --git a/src/services/disputeService.ts b/src/services/disputeService.ts index 4a5e9c6a..21c90653 100644 --- a/src/services/disputeService.ts +++ b/src/services/disputeService.ts @@ -1,57 +1,79 @@ /** * src/services/disputeService.ts * - * In-memory dispute store with state machine and audit trail. + * Dispute-resolution state machine for failed billing deductions. * - * State machine: - * OPEN → REFUNDED (admin action) - * OPEN → UPHELD (admin action) + * State transitions: + * (none) ──openDispute()──► OPEN ──resolveDispute('REFUNDED')──► REFUNDED + * └──resolveDispute('UPHELD')────► UPHELD * - * dispute_events audit trail records every transition. + * Every transition appends an immutable row to `dispute_events` (audit trail). + * Resolving an already-resolved dispute is rejected with 409 ConflictError. + * Opening a second dispute for the same usage_event_id is also 409 ConflictError. */ +import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; -import { ConflictError, NotFoundError, ForbiddenError } from '../errors/index.js'; -import { refundsCache } from './refundsCacheWarm.js'; +import { + ConflictError, + ForbiddenError, + NotFoundError, +} from '../errors/index.js'; + +export type MaybePromise = T | Promise; + +function isPromiseLike(val: MaybePromise): val is Promise { + return Boolean(val && typeof (val as Promise).then === 'function'); +} + +function chainMaybePromise( + val: MaybePromise, + fn: (resolved: T) => MaybePromise, +): MaybePromise { + if (isPromiseLike(val)) { + return val.then((resolved) => fn(resolved)); + } + return fn(val); +} // --------------------------------------------------------------------------- -// Types +// Types & Schemas // --------------------------------------------------------------------------- export type DisputeStatus = 'OPEN' | 'REFUNDED' | 'UPHELD'; +export type DisputeAction = 'OPENED' | 'RESOLVED' | 'NOTE_ADDED'; export interface Dispute { id: string; usage_event_id: string; - opened_by: string; // developer user_id + opened_by: string; reason: string; status: DisputeStatus; + resolved_by: string | null; + resolution_notes: string | null; created_at: string; resolved_at: string | null; - resolved_by: string | null; } export interface DisputeEvent { id: string; dispute_id: string; actor: string; - action: string; - details?: Record; + action: DisputeAction; + notes?: string; created_at: string; } -// --------------------------------------------------------------------------- -// Validation schemas -// --------------------------------------------------------------------------- - export const openDisputeSchema = z.object({ - usage_event_id: z.string().min(1, 'usage_event_id is required'), - reason: z.string().min(1).max(1000), + usage_event_id: z.string().trim().min(1, 'usage_event_id is required'), + reason: z.string().trim().min(1, 'reason is required').max(1000, 'reason must be at most 1000 characters'), }); export const resolveDisputeSchema = z.object({ - resolution: z.enum(['REFUNDED', 'UPHELD']), - notes: z.string().max(1000).optional(), + resolution: z.enum(['REFUNDED', 'UPHELD'], { + error: 'resolution must be REFUNDED or UPHELD', + }), + notes: z.string().trim().max(1000).optional(), }); export type OpenDisputeInput = z.infer; @@ -62,165 +84,387 @@ export type ResolveDisputeInput = z.infer; // --------------------------------------------------------------------------- export interface DisputeRepository { - create(input: OpenDisputeInput, openedBy: string): Dispute; - findById(id: string): Dispute | undefined; - findByUsageEventId(usageEventId: string): Dispute | undefined; - findByUser(userId: string): Dispute[]; - listAll(): Dispute[]; - resolve(id: string, resolution: 'REFUNDED' | 'UPHELD', resolvedBy: string): Dispute; - appendEvent(event: Omit): DisputeEvent; - getEvents(disputeId: string): DisputeEvent[]; + create(input: OpenDisputeInput, openedBy: string): MaybePromise; + findById(id: string): MaybePromise; + findByUser(userId: string): MaybePromise; + listAll(): MaybePromise; + resolve( + id: string, + resolution: 'REFUNDED' | 'UPHELD', + resolvedBy: string, + notes?: string, + ): MaybePromise; + appendEvent(event: Omit): MaybePromise; + getEvents(disputeId: string): MaybePromise; } // --------------------------------------------------------------------------- -// In-memory implementation +// In-memory implementation (used in tests and dev) // --------------------------------------------------------------------------- -let _counter = 0; -function nextId(prefix: string): string { - return `${prefix}_${Date.now()}_${++_counter}`; -} - export class InMemoryDisputeRepository implements DisputeRepository { - private readonly disputes = new Map(); - private readonly events: DisputeEvent[] = []; + private disputes = new Map(); + private byUsageEvent = new Map(); // usage_event_id → dispute.id + private events: DisputeEvent[] = []; create(input: OpenDisputeInput, openedBy: string): Dispute { - // One open dispute per usage_event_id - const existing = this.findByUsageEventId(input.usage_event_id); - if (existing) { + if (this.byUsageEvent.has(input.usage_event_id)) { throw new ConflictError( `A dispute already exists for usage_event_id '${input.usage_event_id}'`, - 'CONFLICT', + 'DISPUTE_ALREADY_EXISTS', ); } const dispute: Dispute = { - id: nextId('disp'), + id: uuidv4(), usage_event_id: input.usage_event_id, opened_by: openedBy, reason: input.reason, status: 'OPEN', + resolved_by: null, + resolution_notes: null, created_at: new Date().toISOString(), resolved_at: null, - resolved_by: null, }; + this.disputes.set(dispute.id, dispute); - return dispute; + this.byUsageEvent.set(input.usage_event_id, dispute.id); + return { ...dispute }; } findById(id: string): Dispute | undefined { - return this.disputes.get(id); - } - - findByUsageEventId(usageEventId: string): Dispute | undefined { - for (const d of this.disputes.values()) { - if (d.usage_event_id === usageEventId) return d; - } - return undefined; + const d = this.disputes.get(id); + return d ? { ...d } : undefined; } findByUser(userId: string): Dispute[] { - return Array.from(this.disputes.values()).filter(d => d.opened_by === userId); + return Array.from(this.disputes.values()) + .filter((d) => d.opened_by === userId) + .map((d) => ({ ...d })); } listAll(): Dispute[] { - return Array.from(this.disputes.values()); + return Array.from(this.disputes.values()).map((d) => ({ ...d })); } - resolve(id: string, resolution: 'REFUNDED' | 'UPHELD', resolvedBy: string): Dispute { - const dispute = this.disputes.get(id); - if (!dispute) throw new NotFoundError(`Dispute '${id}' not found`); - if (dispute.status !== 'OPEN') { - throw new ConflictError(`Dispute '${id}' is already ${dispute.status}`, 'CONFLICT'); + resolve( + id: string, + resolution: 'REFUNDED' | 'UPHELD', + resolvedBy: string, + notes?: string, + ): Dispute { + const existing = this.disputes.get(id); + if (!existing) { + throw new NotFoundError(`Dispute '${id}' not found`); + } + if (existing.status !== 'OPEN') { + throw new ConflictError( + `Dispute '${id}' is already ${existing.status} and cannot be resolved again`, + 'DISPUTE_ALREADY_RESOLVED', + ); } const updated: Dispute = { - ...dispute, + ...existing, status: resolution, - resolved_at: new Date().toISOString(), resolved_by: resolvedBy, + resolution_notes: notes ?? existing.resolution_notes ?? null, + resolved_at: new Date().toISOString(), }; + this.disputes.set(id, updated); - return updated; + return { ...updated }; } appendEvent(event: Omit): DisputeEvent { - const full: DisputeEvent = { + const record: DisputeEvent = { ...event, - id: nextId('devt'), + id: uuidv4(), created_at: new Date().toISOString(), }; - this.events.push(full); - return full; + this.events.push(record); + return { ...record }; } getEvents(disputeId: string): DisputeEvent[] { - return this.events.filter(e => e.dispute_id === disputeId); + return this.events + .filter((e) => e.dispute_id === disputeId) + .map((e) => ({ ...e })); + } +} + +// --------------------------------------------------------------------------- +// Postgres-backed implementation (targets migrations/0019_disputes.sql) +// --------------------------------------------------------------------------- + +export interface DisputeQueryResult> { + rows: R[]; + rowCount?: number | null; +} + +export interface DisputeDb { + query>( + text: string, + params?: unknown[], + ): Promise>; +} + +interface DisputeRow { + id: string; + usage_event_id: string; + opened_by: string; + reason: string; + status: DisputeStatus; + resolved_by: string | null; + resolution_notes: string | null; + created_at: Date | string; + resolved_at: Date | string | null; +} + +interface DisputeEventRow { + id: string; + dispute_id: string; + actor: string; + action: DisputeAction; + notes: string | null; + created_at: Date | string; +} + +function toIsoTimestamp(value: Date | string | null | undefined): string | null { + if (value === null || value === undefined) return null; + if (value instanceof Date) return value.toISOString(); + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toISOString(); +} + +function mapDisputeRow(row: DisputeRow): Dispute { + return { + id: row.id, + usage_event_id: row.usage_event_id, + opened_by: row.opened_by, + reason: row.reason, + status: row.status, + resolved_by: row.resolved_by ?? null, + resolution_notes: row.resolution_notes ?? null, + created_at: toIsoTimestamp(row.created_at) ?? new Date().toISOString(), + resolved_at: toIsoTimestamp(row.resolved_at), + }; +} + +function mapDisputeEventRow(row: DisputeEventRow): DisputeEvent { + return { + id: row.id, + dispute_id: row.dispute_id, + actor: row.actor, + action: row.action, + ...(row.notes !== null && row.notes !== undefined ? { notes: row.notes } : {}), + created_at: toIsoTimestamp(row.created_at) ?? new Date().toISOString(), + }; +} + +function isUniqueViolationError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const pgErr = err as { code?: string; message?: string; constraint?: string }; + if (pgErr.code === '23505') return true; + if (typeof pgErr.constraint === 'string' && pgErr.constraint.includes('usage_event')) { + return true; + } + if ( + typeof pgErr.message === 'string' && + /duplicate key|unique constraint|uq_disputes_usage_event/i.test(pgErr.message) + ) { + return true; + } + return false; +} + +export class PgDisputeRepository implements DisputeRepository { + constructor(private readonly db: DisputeDb) {} + + async create(input: OpenDisputeInput, openedBy: string): Promise { + const id = uuidv4(); + const nowIso = new Date().toISOString(); + try { + const result = await this.db.query( + `INSERT INTO disputes ( + id, usage_event_id, opened_by, reason, status, resolved_by, resolution_notes, created_at, resolved_at + ) VALUES ($1, $2, $3, $4, 'OPEN', NULL, NULL, $5, NULL) + RETURNING id, usage_event_id, opened_by, reason, status, resolved_by, resolution_notes, created_at, resolved_at`, + [id, input.usage_event_id, openedBy, input.reason, nowIso], + ); + return mapDisputeRow(result.rows[0]); + } catch (err) { + if (isUniqueViolationError(err)) { + throw new ConflictError( + `A dispute already exists for usage_event_id '${input.usage_event_id}'`, + 'DISPUTE_ALREADY_EXISTS', + ); + } + throw err; + } + } + + async findById(id: string): Promise { + const result = await this.db.query( + `SELECT id, usage_event_id, opened_by, reason, status, resolved_by, resolution_notes, created_at, resolved_at + FROM disputes + WHERE id = $1`, + [id], + ); + return result.rows[0] ? mapDisputeRow(result.rows[0]) : undefined; + } + + async findByUser(userId: string): Promise { + const result = await this.db.query( + `SELECT id, usage_event_id, opened_by, reason, status, resolved_by, resolution_notes, created_at, resolved_at + FROM disputes + WHERE opened_by = $1 + ORDER BY created_at DESC`, + [userId], + ); + return result.rows.map(mapDisputeRow); + } + + async listAll(): Promise { + const result = await this.db.query( + `SELECT id, usage_event_id, opened_by, reason, status, resolved_by, resolution_notes, created_at, resolved_at + FROM disputes + ORDER BY created_at DESC`, + ); + return result.rows.map(mapDisputeRow); + } + + async resolve( + id: string, + resolution: 'REFUNDED' | 'UPHELD', + resolvedBy: string, + notes?: string, + ): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new NotFoundError(`Dispute '${id}' not found`); + } + if (existing.status !== 'OPEN') { + throw new ConflictError( + `Dispute '${id}' is already ${existing.status} and cannot be resolved again`, + 'DISPUTE_ALREADY_RESOLVED', + ); + } + + const nowIso = new Date().toISOString(); + const updated = await this.db.query( + `UPDATE disputes + SET status = $2, + resolved_by = $3, + resolution_notes = $4, + resolved_at = $5 + WHERE id = $1 AND status = 'OPEN' + RETURNING id, usage_event_id, opened_by, reason, status, resolved_by, resolution_notes, created_at, resolved_at`, + [id, resolution, resolvedBy, notes ?? existing.resolution_notes ?? null, nowIso], + ); + + if (!updated.rows[0]) { + throw new ConflictError( + `Dispute '${id}' is already resolved and cannot be resolved again`, + 'DISPUTE_ALREADY_RESOLVED', + ); + } + + return mapDisputeRow(updated.rows[0]); + } + + async appendEvent( + event: Omit, + ): Promise { + const id = uuidv4(); + const nowIso = new Date().toISOString(); + const result = await this.db.query( + `INSERT INTO dispute_events (id, dispute_id, actor, action, notes, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, dispute_id, actor, action, notes, created_at`, + [id, event.dispute_id, event.actor, event.action, event.notes ?? null, nowIso], + ); + return mapDisputeEventRow(result.rows[0]); + } + + async getEvents(disputeId: string): Promise { + const result = await this.db.query( + `SELECT id, dispute_id, actor, action, notes, created_at + FROM dispute_events + WHERE dispute_id = $1 + ORDER BY created_at ASC`, + [disputeId], + ); + return result.rows.map(mapDisputeEventRow); } } // --------------------------------------------------------------------------- -// Service layer (enforces RBAC + audit trail) +// Service // --------------------------------------------------------------------------- export class DisputeService { constructor(private readonly repo: DisputeRepository) {} - openDispute(input: OpenDisputeInput, openedBy: string): Dispute { - const dispute = this.repo.create(input, openedBy); - this.repo.appendEvent({ - dispute_id: dispute.id, - actor: openedBy, - action: 'OPENED', - details: { usage_event_id: input.usage_event_id, reason: input.reason }, - }); - return dispute; + openDispute(input: OpenDisputeInput, actor: string): MaybePromise { + return chainMaybePromise(this.repo.create(input, actor), (dispute) => + chainMaybePromise( + this.repo.appendEvent({ + dispute_id: dispute.id, + actor, + action: 'OPENED', + notes: input.reason, + }), + () => dispute, + ), + ); } resolveDispute( - disputeId: string, + id: string, input: ResolveDisputeInput, adminActor: string, - ): Dispute { - const dispute = this.repo.resolve(disputeId, input.resolution, adminActor); - this.repo.appendEvent({ - dispute_id: dispute.id, - actor: adminActor, - action: 'RESOLVED', - details: { resolution: input.resolution, notes: input.notes }, - }); - if (input.resolution === 'REFUNDED') { - refundsCache.invalidateAll(); - } - return dispute; + ): MaybePromise { + return chainMaybePromise( + this.repo.resolve(id, input.resolution, adminActor, input.notes), + (dispute) => + chainMaybePromise( + this.repo.appendEvent({ + dispute_id: dispute.id, + actor: adminActor, + action: 'RESOLVED', + notes: input.notes ?? `Resolved as ${input.resolution}`, + }), + () => dispute, + ), + ); } - getDisputeForDeveloper(disputeId: string, userId: string): Dispute { - const dispute = this.repo.findById(disputeId); - if (!dispute) throw new NotFoundError(`Dispute '${disputeId}' not found`); - if (dispute.opened_by !== userId) { - throw new ForbiddenError('You do not have access to this dispute'); - } - return dispute; + /** Developer-scoped fetch — throws ForbiddenError if not the owner. */ + getDisputeForDeveloper(id: string, developerId: string): MaybePromise { + return chainMaybePromise(this.repo.findById(id), (dispute) => { + if (!dispute) { + throw new NotFoundError(`Dispute '${id}' not found`); + } + if (dispute.opened_by !== developerId) { + throw new ForbiddenError('You do not have access to this dispute'); + } + return dispute; + }); } - listForDeveloper(userId: string): Dispute[] { - return this.repo.findByUser(userId); + getEvents(disputeId: string): MaybePromise { + return this.repo.getEvents(disputeId); } - listAll(): Dispute[] { - return this.repo.listAll(); + listForDeveloper(developerId: string): MaybePromise { + return this.repo.findByUser(developerId); } - getEvents(disputeId: string): DisputeEvent[] { - return this.repo.getEvents(disputeId); + listAll(): MaybePromise { + return this.repo.listAll(); } } -// --------------------------------------------------------------------------- -// Singleton default instances -// --------------------------------------------------------------------------- - export const defaultDisputeRepository = new InMemoryDisputeRepository(); export const defaultDisputeService = new DisputeService(defaultDisputeRepository);