Skip to content

Commit 81b0977

Browse files
authored
resolve dispute (#228)
1 parent 73d572e commit 81b0977

12 files changed

Lines changed: 1763 additions & 12 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
-- Payment Dispute Management
2+
-- Creates tables for payment disputes and associated evidence.
3+
4+
-- -------------------------------------------------------------------------
5+
-- payment_disputes
6+
-- -------------------------------------------------------------------------
7+
CREATE TABLE IF NOT EXISTS payment_disputes (
8+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
9+
payment_id UUID NOT NULL,
10+
filed_by_user_id VARCHAR(255) NOT NULL,
11+
reason VARCHAR(50) NOT NULL,
12+
description TEXT NOT NULL,
13+
status VARCHAR(50) NOT NULL DEFAULT 'open',
14+
disputed_amount BIGINT NOT NULL,
15+
resolution_notes TEXT,
16+
resolved_by VARCHAR(255),
17+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
18+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
19+
20+
CONSTRAINT fk_dispute_payment
21+
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE RESTRICT,
22+
23+
CONSTRAINT fk_dispute_filed_by
24+
FOREIGN KEY (filed_by_user_id) REFERENCES users(user_id) ON DELETE RESTRICT,
25+
26+
CONSTRAINT chk_dispute_status CHECK (
27+
status IN ('open', 'under_review', 'resolved_customer', 'resolved_merchant', 'closed')
28+
),
29+
30+
CONSTRAINT chk_dispute_reason CHECK (
31+
reason IN ('unauthorized', 'not_delivered', 'not_as_described', 'incorrect_amount', 'duplicate', 'other')
32+
),
33+
34+
CONSTRAINT chk_disputed_amount CHECK (disputed_amount > 0)
35+
);
36+
37+
-- -------------------------------------------------------------------------
38+
-- dispute_evidence
39+
-- -------------------------------------------------------------------------
40+
CREATE TABLE IF NOT EXISTS dispute_evidence (
41+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
42+
dispute_id UUID NOT NULL,
43+
submitted_by_user_id VARCHAR(255) NOT NULL,
44+
evidence_type VARCHAR(100) NOT NULL,
45+
description TEXT NOT NULL,
46+
file_url TEXT,
47+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
48+
49+
CONSTRAINT fk_evidence_dispute
50+
FOREIGN KEY (dispute_id) REFERENCES payment_disputes(id) ON DELETE CASCADE,
51+
52+
CONSTRAINT fk_evidence_submitted_by
53+
FOREIGN KEY (submitted_by_user_id) REFERENCES users(user_id) ON DELETE RESTRICT
54+
);
55+
56+
-- -------------------------------------------------------------------------
57+
-- Indexes
58+
-- -------------------------------------------------------------------------
59+
CREATE INDEX IF NOT EXISTS idx_disputes_payment_id
60+
ON payment_disputes(payment_id);
61+
62+
CREATE INDEX IF NOT EXISTS idx_disputes_filed_by
63+
ON payment_disputes(filed_by_user_id);
64+
65+
CREATE INDEX IF NOT EXISTS idx_disputes_status
66+
ON payment_disputes(status);
67+
68+
CREATE INDEX IF NOT EXISTS idx_disputes_created_at
69+
ON payment_disputes(created_at DESC);
70+
71+
CREATE INDEX IF NOT EXISTS idx_evidence_dispute_id
72+
ON dispute_evidence(dispute_id);
73+
74+
-- -------------------------------------------------------------------------
75+
-- Trigger: auto-update updated_at on payment_disputes
76+
-- -------------------------------------------------------------------------
77+
CREATE OR REPLACE FUNCTION update_dispute_updated_at()
78+
RETURNS TRIGGER AS $$
79+
BEGIN
80+
NEW.updated_at = NOW();
81+
RETURN NEW;
82+
END;
83+
$$ LANGUAGE plpgsql;
84+
85+
DROP TRIGGER IF EXISTS trg_dispute_updated_at ON payment_disputes;
86+
CREATE TRIGGER trg_dispute_updated_at
87+
BEFORE UPDATE ON payment_disputes
88+
FOR EACH ROW
89+
EXECUTE FUNCTION update_dispute_updated_at();

backend/src/app.rs

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ use tower_http::{cors::CorsLayer, trace::TraceLayer};
1010
use crate::{
1111
config::Config,
1212
http::{
13-
admin, audit, auth, files, health, identity, jobs, metrics as metrics_http, notifications,
14-
payments, profiles, transfers, withdrawals,
13+
admin, audit, auth, disputes, files, health, identity, jobs, metrics as metrics_http,
14+
notifications, payments, profiles, transfers, version as version_http, withdrawals,
1515
},
1616
job_worker::JobWorker,
1717
middleware::{
1818
audit_logging, auth as auth_middleware, metrics, rate_limit, request_id, role_guard,
19+
version_middleware,
1920
},
2021
role::Role,
2122
service::{MetricsService, ServiceContainer},
@@ -38,6 +39,10 @@ pub async fn create_app(
3839
}
3940
});
4041

42+
// =========================================================================
43+
// Route definitions
44+
// =========================================================================
45+
4146
// -------------------- Health --------------------
4247
let health_routes = Router::new()
4348
.route("/health", get(health::health_check))
@@ -106,15 +111,14 @@ pub async fn create_app(
106111
.route("/:user_id", patch(profiles::update_profile))
107112
.route("/:user_id", delete(profiles::delete_profile));
108113

109-
// -------------------- Admin --------------------
110-
// Files routes
114+
// -------------------- Files --------------------
111115
let files_routes = Router::new()
112116
.route("/upload", post(files::upload_file))
113117
.route("/:id", get(files::get_file))
114118
.route("/:id/meta", get(files::get_file_metadata))
115119
.route("/:id", delete(files::delete_file));
116120

117-
// Admin routes (protected)
121+
// -------------------- Admin --------------------
118122
let admin_routes = Router::new()
119123
.route("/dashboard/stats", get(admin::get_dashboard_stats))
120124
.route("/transactions", get(admin::get_transactions))
@@ -128,11 +132,45 @@ pub async fn create_app(
128132
.route("/audit-logs/:id", get(audit::get_audit_log))
129133
.layer(middleware::from_fn(role_guard::admin_only()));
130134

135+
// -------------------- Disputes (v2 only) --------------------
136+
// Payment-scoped dispute routes
137+
let payment_dispute_routes = Router::new()
138+
.route(
139+
"/payments/:payment_id/disputes",
140+
post(disputes::file_dispute),
141+
)
142+
.route(
143+
"/payments/:payment_id/disputes",
144+
get(disputes::list_payment_disputes),
145+
);
146+
147+
// Standalone dispute routes
148+
let dispute_routes = Router::new()
149+
.route("/disputes", get(disputes::list_all_disputes))
150+
.route("/disputes/me", get(disputes::list_my_disputes))
151+
.route("/disputes/:dispute_id", get(disputes::get_dispute))
152+
.route(
153+
"/disputes/:dispute_id/status",
154+
patch(disputes::update_dispute_status),
155+
)
156+
.route(
157+
"/disputes/:dispute_id/evidence",
158+
post(disputes::add_evidence),
159+
)
160+
.route(
161+
"/disputes/:dispute_id/evidence",
162+
get(disputes::list_evidence),
163+
);
164+
131165
// -------------------- Jobs --------------------
132166
let _job_routes = jobs::create_job_routes();
133167

134-
// -------------------- Protected Routes --------------------
135-
let protected_routes = Router::new()
168+
// =========================================================================
169+
// Protected route bundles (auth + rate-limit + audit middleware applied)
170+
// =========================================================================
171+
172+
// Shared protected routes (available on both /api/v1 and /api/v2)
173+
let shared_protected = Router::new()
136174
.nest("/identity", identity_routes)
137175
.nest("/payments", payment_routes)
138176
.nest("/transfers", transfer_routes)
@@ -141,7 +179,30 @@ pub async fn create_app(
141179
.nest("/profiles", profile_routes)
142180
.nest("/files", files_routes)
143181
.nest("/admin", admin_routes)
144-
.nest("/audit", audit_routes)
182+
.nest("/audit", audit_routes);
183+
184+
// v2-only protected routes (disputes)
185+
let v2_only_protected = Router::new()
186+
.merge(payment_dispute_routes)
187+
.merge(dispute_routes);
188+
189+
// Apply auth/rate-limit/audit middleware to shared routes
190+
let shared_protected_with_middleware = shared_protected
191+
.layer(middleware::from_fn_with_state(
192+
services.clone(),
193+
audit_logging,
194+
))
195+
.layer(middleware::from_fn_with_state(
196+
services.clone(),
197+
auth_middleware::authenticate,
198+
))
199+
.layer(middleware::from_fn_with_state(
200+
services.clone(),
201+
rate_limit::rate_limit,
202+
));
203+
204+
// Apply auth/rate-limit/audit middleware to v2-only routes
205+
let v2_only_protected_with_middleware = v2_only_protected
145206
.layer(middleware::from_fn_with_state(
146207
services.clone(),
147208
audit_logging,
@@ -155,20 +216,56 @@ pub async fn create_app(
155216
rate_limit::rate_limit,
156217
));
157218

158-
// -------------------- Anchor --------------------
159-
let anchor_routes = Router::new().route("/webhook", post(crate::http::anchor::anchor_webhook));
219+
// =========================================================================
220+
// Versioned API routes
221+
// =========================================================================
222+
223+
// /api/v1 — all shared routes, no dispute endpoints
224+
let api_v1 = Router::new()
225+
.merge(shared_protected_with_middleware.clone())
226+
.layer(middleware::from_fn(version_middleware));
227+
228+
// /api/v2 — shared routes + dispute endpoints
229+
let api_v2 = Router::new()
230+
.merge(shared_protected_with_middleware)
231+
.merge(v2_only_protected_with_middleware)
232+
.layer(middleware::from_fn(version_middleware));
233+
234+
// =========================================================================
235+
// Version documentation routes (public, no auth)
236+
// =========================================================================
237+
let version_doc_routes = Router::new()
238+
.route("/versions", get(version_http::list_versions))
239+
.route("/versions/:version", get(version_http::get_version))
240+
.route(
241+
"/versions/:version/migration",
242+
get(version_http::get_migration_guide),
243+
);
244+
245+
// =========================================================================
246+
// Anchor & public routes
247+
// =========================================================================
248+
let anchor_routes =
249+
Router::new().route("/webhook", post(crate::http::anchor::anchor_webhook));
160250

161-
// -------------------- Public Routes --------------------
162251
let public_routes = Router::new()
163252
.nest("/anchor", anchor_routes)
164253
.nest("/auth", auth_routes)
165254
.nest("/user", user_routes)
166255
.nest("/health", health_routes)
167256
.merge(metrics_routes);
168257

258+
// =========================================================================
259+
// Assemble the full router
260+
// =========================================================================
169261
let app = Router::new()
262+
// Versioned API namespaces
263+
.nest("/api/v1", api_v1)
264+
.nest("/api/v2", api_v2)
265+
// Version documentation (public)
266+
.nest("/api", version_doc_routes)
267+
// Legacy unversioned public routes (backward compat)
170268
.merge(public_routes)
171-
.merge(protected_routes)
172269
.with_state(services)
173270
.layer(middleware::from_fn(request_id::request_id))
174271
.layer(middleware::from_fn(metrics::track_metrics))

0 commit comments

Comments
 (0)