Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 27 additions & 26 deletions migrations/0019_disputes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
19 changes: 19 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Developer | undefined>;
Expand Down Expand Up @@ -454,6 +463,16 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
// 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",
Expand Down
165 changes: 150 additions & 15 deletions src/routes/billing/disputes.test.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -17,22 +18,59 @@ 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';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

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<typeof supertest>) {
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<string, string>, 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();
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -154,29 +202,116 @@ 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
// ---------------------------------------------------------------------------

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<InMemoryDisputeRepository['create']>;
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<InMemoryDisputeRepository['create']>;
const resolved = svc.resolveDispute(d.id, { resolution: 'REFUNDED' }, 'admin') as ReturnType<InMemoryDisputeRepository['create']>;
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<InMemoryDisputeRepository['create']>;
expect(() => svc.getDisputeForDeveloper(d.id, 'u2')).toThrow(/do not have access/);
});

Expand Down Expand Up @@ -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<InMemoryDisputeRepository['create']>;
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<InMemoryDisputeRepository['create']>;
const res = await request(buildApp(svc))
.get(`/api/billing/disputes/${d.id}`)
.set('x-user-id', 'u2');
Expand All @@ -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<InMemoryDisputeRepository['create']>;
const res = await request(buildApp(svc))
.get(`/api/billing/disputes/${d.id}`)
.set('x-user-id', 'u1');
Expand All @@ -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<InMemoryDisputeRepository['create']>;
const res = await request(buildApp(svc))
.post(`/api/billing/disputes/${d.id}/resolve`)
.send({ resolution: 'REFUNDED' });
Expand All @@ -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<InMemoryDisputeRepository['create']>;
const res = await request(buildApp(svc))
.post(`/api/billing/disputes/${d.id}/resolve`)
.set('x-admin-api-key', ADMIN_KEY)
Expand All @@ -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<InMemoryDisputeRepository['create']>;
const res = await request(buildApp(svc))
.post(`/api/billing/disputes/${d.id}/resolve`)
.set('x-admin-api-key', ADMIN_KEY)
Expand All @@ -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<InMemoryDisputeRepository['create']>;
const res = await request(buildApp(svc))
.post(`/api/billing/disputes/${d.id}/resolve`)
.set('x-admin-api-key', ADMIN_KEY)
Expand All @@ -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<InMemoryDisputeRepository['create']>;
svc.resolveDispute(d.id, { resolution: 'UPHELD' }, 'admin');
const res = await request(buildApp(svc))
.post(`/api/billing/disputes/${d.id}/resolve`)
Expand Down
Loading