backbone_accounting/presentation/http/
journal_workflow_handler.rs1use std::sync::Arc;
22
23use axum::{
24 extract::Path, extract::Query, extract::State, response::IntoResponse, routing::post, Json,
25 Router,
26};
27use serde::{Deserialize, Serialize};
28use uuid::Uuid;
29
30use backbone_auth::org::OrgContext;
31
32use crate::application::service::journal_workflow_service::{
33 JournalWorkflowError, JournalWorkflowService,
34};
35
36#[derive(Debug, Deserialize)]
37pub struct CompanyQuery {
38}
39
40#[derive(Debug, Deserialize)]
41pub struct ApproveBody {
42 pub approved_by: Option<Uuid>,
44}
45
46#[derive(Debug, Deserialize)]
47pub struct RejectBody {
48 pub reason: String,
49 pub rejected_by: Option<Uuid>,
51}
52
53#[derive(Debug, Deserialize)]
54pub struct VoidBody {
55 pub voided_by: Option<Uuid>,
57 pub reason: String,
58}
59
60#[derive(Debug, Serialize)]
61struct WorkflowResponse {
62 success: bool,
63 journal_id: Uuid,
64 status: &'static str,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 post_id: Option<Uuid>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 idempotent_reuse: Option<bool>,
69}
70
71fn err_response(e: JournalWorkflowError) -> (axum::http::StatusCode, Json<serde_json::Value>) {
72 let status = axum::http::StatusCode::from_u16(e.http_status())
73 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
74 (
75 status,
76 Json(serde_json::json!({
77 "success": false,
78 "error": e.code(),
79 "message": e.to_string(),
80 })),
81 )
82}
83
84async fn submit(
85 State(svc): State<Arc<JournalWorkflowService>>,
86 org: OrgContext,
87 Path(id): Path<Uuid>,
88 Query(_q): Query<CompanyQuery>,
89) -> impl IntoResponse {
90 run_submit(svc, id, actor(&org)).await
91}
92
93async fn run_submit(svc: Arc<JournalWorkflowService>, id: Uuid, submitted_by: Option<Uuid>) -> axum::response::Response {
95 match svc.submit_as(id, submitted_by).await {
96 Ok(()) => Json(WorkflowResponse {
97 success: true,
98 journal_id: id,
99 status: "pending_approval",
100 post_id: None,
101 idempotent_reuse: None,
102 })
103 .into_response(),
104 Err(e) => err_response(e).into_response(),
105 }
106}
107
108fn actor(org: &OrgContext) -> Option<Uuid> {
110 Uuid::parse_str(&org.user_id).ok()
111}
112
113async fn approve(
114 State(svc): State<Arc<JournalWorkflowService>>,
115 org: OrgContext,
116 Path(id): Path<Uuid>,
117 Json(_body): Json<ApproveBody>,
118) -> impl IntoResponse {
119 run_approve(svc, id, actor(&org)).await
120}
121
122async fn reject(
123 State(svc): State<Arc<JournalWorkflowService>>,
124 org: OrgContext,
125 Path(id): Path<Uuid>,
126 Json(body): Json<RejectBody>,
127) -> impl IntoResponse {
128 run_reject(svc, id, body.reason, actor(&org)).await
129}
130
131async fn void(
132 State(svc): State<Arc<JournalWorkflowService>>,
133 org: OrgContext,
134 Path(id): Path<Uuid>,
135 Json(body): Json<VoidBody>,
136) -> impl IntoResponse {
137 run_void(svc, id, actor(&org), body.reason).await
138}
139
140async fn run_approve(
142 svc: Arc<JournalWorkflowService>,
143 id: Uuid,
144 approved_by: Option<Uuid>,
145) -> axum::response::Response {
146 match svc.approve(id, approved_by).await {
147 Ok(r) => Json(WorkflowResponse {
148 success: true,
149 journal_id: r.journal_id,
150 status: "posted",
151 post_id: Some(r.post_id),
152 idempotent_reuse: Some(r.idempotent_reuse),
153 })
154 .into_response(),
155 Err(e) => err_response(e).into_response(),
156 }
157}
158
159async fn run_reject(
161 svc: Arc<JournalWorkflowService>,
162 id: Uuid,
163 reason: String,
164 rejected_by: Option<Uuid>,
165) -> axum::response::Response {
166 match svc.reject(id, reason, rejected_by).await {
167 Ok(()) => Json(WorkflowResponse {
168 success: true,
169 journal_id: id,
170 status: "rejected",
171 post_id: None,
172 idempotent_reuse: None,
173 })
174 .into_response(),
175 Err(e) => err_response(e).into_response(),
176 }
177}
178
179async fn run_void(
181 svc: Arc<JournalWorkflowService>,
182 id: Uuid,
183 voided_by: Option<Uuid>,
184 reason: String,
185) -> axum::response::Response {
186 match svc.void(id, voided_by, reason).await {
187 Ok(r) => Json(WorkflowResponse {
188 success: true,
189 journal_id: id,
190 status: "voided",
191 post_id: Some(r.post_id),
192 idempotent_reuse: Some(r.idempotent_reuse),
193 })
194 .into_response(),
195 Err(e) => err_response(e).into_response(),
196 }
197}
198
199pub fn create_journal_workflow_routes(service: Arc<JournalWorkflowService>) -> Router {
202 Router::new()
203 .route("/journals/:id/submit", post(submit))
204 .route("/journals/:id/approve", post(approve))
205 .route("/journals/:id/reject", post(reject))
206 .route("/journals/:id/void", post(void))
207 .with_state(service)
208}
209
210#[cfg(feature = "auth")]
221use axum::Extension;
222
223#[cfg(feature = "auth")]
224use backbone_auth::{middleware::AuthContext, AuthMiddleware};
225
226#[cfg(feature = "auth")]
227fn principal(auth: &AuthContext) -> Option<Uuid> {
228 Uuid::parse_str(&auth.user_id).ok()
229}
230
231#[cfg(feature = "auth")]
232async fn submit_protected(
233 State(svc): State<Arc<JournalWorkflowService>>,
234 Extension(auth): Extension<AuthContext>,
235 Path(id): Path<Uuid>,
236) -> impl IntoResponse {
237 run_submit(svc, id, principal(&auth)).await
238}
239
240#[cfg(feature = "auth")]
241async fn approve_protected(
242 State(svc): State<Arc<JournalWorkflowService>>,
243 Extension(auth): Extension<AuthContext>,
244 Path(id): Path<Uuid>,
245 Json(_body): Json<ApproveBody>,
246) -> impl IntoResponse {
247 run_approve(svc, id, principal(&auth)).await
248}
249
250#[cfg(feature = "auth")]
251async fn reject_protected(
252 State(svc): State<Arc<JournalWorkflowService>>,
253 Extension(auth): Extension<AuthContext>,
254 Path(id): Path<Uuid>,
255 Json(body): Json<RejectBody>,
256) -> impl IntoResponse {
257 run_reject(svc, id, body.reason, principal(&auth)).await
258}
259
260#[cfg(feature = "auth")]
261async fn void_protected(
262 State(svc): State<Arc<JournalWorkflowService>>,
263 Extension(auth): Extension<AuthContext>,
264 Path(id): Path<Uuid>,
265 Json(body): Json<VoidBody>,
266) -> impl IntoResponse {
267 run_void(svc, id, principal(&auth), body.reason).await
268}
269
270#[cfg(feature = "auth")]
271pub fn create_protected_journal_workflow_routes<A: AuthMiddleware + Send + Sync + 'static>(
273 service: Arc<JournalWorkflowService>,
274 auth: Arc<A>,
275) -> Router {
276 use axum::{middleware, response::IntoResponse};
277
278 let auth_layer = auth.clone();
279 Router::new()
280 .route("/journals/:id/submit", post(submit_protected))
281 .route("/journals/:id/approve", post(approve_protected))
282 .route("/journals/:id/reject", post(reject_protected))
283 .route("/journals/:id/void", post(void_protected))
284 .with_state(service)
285 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
286 let auth = auth_layer.clone();
287 async move {
288 let token = req
289 .headers()
290 .get(axum::http::header::AUTHORIZATION)
291 .and_then(|h| h.to_str().ok())
292 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
293 .unwrap_or("");
294 match auth.authenticate(token).await {
295 Ok(ctx) => {
296 req.extensions_mut().insert(ctx);
297 next.run(req).await
298 }
299 Err(_) => (
300 axum::http::StatusCode::UNAUTHORIZED,
301 axum::Json(serde_json::json!({
302 "success": false, "error": "unauthorized", "message": "Authentication required"
303 })),
304 ).into_response(),
305 }
306 }
307 }))
308}