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 Path(id): Path<Uuid>,
87 Query(_q): Query<CompanyQuery>,
88) -> impl IntoResponse {
89 match svc.submit(id).await {
90 Ok(()) => Json(WorkflowResponse {
91 success: true,
92 journal_id: id,
93 status: "pending_approval",
94 post_id: None,
95 idempotent_reuse: None,
96 })
97 .into_response(),
98 Err(e) => err_response(e).into_response(),
99 }
100}
101
102fn actor(org: &OrgContext) -> Option<Uuid> {
104 Uuid::parse_str(&org.user_id).ok()
105}
106
107async fn approve(
108 State(svc): State<Arc<JournalWorkflowService>>,
109 org: OrgContext,
110 Path(id): Path<Uuid>,
111 Json(_body): Json<ApproveBody>,
112) -> impl IntoResponse {
113 run_approve(svc, id, actor(&org)).await
114}
115
116async fn reject(
117 State(svc): State<Arc<JournalWorkflowService>>,
118 org: OrgContext,
119 Path(id): Path<Uuid>,
120 Json(body): Json<RejectBody>,
121) -> impl IntoResponse {
122 run_reject(svc, id, body.reason, actor(&org)).await
123}
124
125async fn void(
126 State(svc): State<Arc<JournalWorkflowService>>,
127 org: OrgContext,
128 Path(id): Path<Uuid>,
129 Json(body): Json<VoidBody>,
130) -> impl IntoResponse {
131 run_void(svc, id, actor(&org), body.reason).await
132}
133
134async fn run_approve(
136 svc: Arc<JournalWorkflowService>,
137 id: Uuid,
138 approved_by: Option<Uuid>,
139) -> axum::response::Response {
140 match svc.approve(id, approved_by).await {
141 Ok(r) => Json(WorkflowResponse {
142 success: true,
143 journal_id: r.journal_id,
144 status: "posted",
145 post_id: Some(r.post_id),
146 idempotent_reuse: Some(r.idempotent_reuse),
147 })
148 .into_response(),
149 Err(e) => err_response(e).into_response(),
150 }
151}
152
153async fn run_reject(
155 svc: Arc<JournalWorkflowService>,
156 id: Uuid,
157 reason: String,
158 rejected_by: Option<Uuid>,
159) -> axum::response::Response {
160 match svc.reject(id, reason, rejected_by).await {
161 Ok(()) => Json(WorkflowResponse {
162 success: true,
163 journal_id: id,
164 status: "rejected",
165 post_id: None,
166 idempotent_reuse: None,
167 })
168 .into_response(),
169 Err(e) => err_response(e).into_response(),
170 }
171}
172
173async fn run_void(
175 svc: Arc<JournalWorkflowService>,
176 id: Uuid,
177 voided_by: Option<Uuid>,
178 reason: String,
179) -> axum::response::Response {
180 match svc.void(id, voided_by, reason).await {
181 Ok(r) => Json(WorkflowResponse {
182 success: true,
183 journal_id: id,
184 status: "voided",
185 post_id: Some(r.post_id),
186 idempotent_reuse: Some(r.idempotent_reuse),
187 })
188 .into_response(),
189 Err(e) => err_response(e).into_response(),
190 }
191}
192
193pub fn create_journal_workflow_routes(service: Arc<JournalWorkflowService>) -> Router {
196 Router::new()
197 .route("/journals/:id/submit", post(submit))
198 .route("/journals/:id/approve", post(approve))
199 .route("/journals/:id/reject", post(reject))
200 .route("/journals/:id/void", post(void))
201 .with_state(service)
202}
203
204#[cfg(feature = "auth")]
215use axum::Extension;
216
217#[cfg(feature = "auth")]
218use backbone_auth::{middleware::AuthContext, AuthMiddleware};
219
220#[cfg(feature = "auth")]
221fn principal(auth: &AuthContext) -> Option<Uuid> {
222 Uuid::parse_str(&auth.user_id).ok()
223}
224
225#[cfg(feature = "auth")]
226async fn approve_protected(
227 State(svc): State<Arc<JournalWorkflowService>>,
228 Extension(auth): Extension<AuthContext>,
229 Path(id): Path<Uuid>,
230 Json(body): Json<ApproveBody>,
231) -> impl IntoResponse {
232 run_approve(svc, id, principal(&auth)).await
233}
234
235#[cfg(feature = "auth")]
236async fn reject_protected(
237 State(svc): State<Arc<JournalWorkflowService>>,
238 Extension(auth): Extension<AuthContext>,
239 Path(id): Path<Uuid>,
240 Json(body): Json<RejectBody>,
241) -> impl IntoResponse {
242 run_reject(svc, id, body.reason, principal(&auth)).await
243}
244
245#[cfg(feature = "auth")]
246async fn void_protected(
247 State(svc): State<Arc<JournalWorkflowService>>,
248 Extension(auth): Extension<AuthContext>,
249 Path(id): Path<Uuid>,
250 Json(body): Json<VoidBody>,
251) -> impl IntoResponse {
252 run_void(svc, id, principal(&auth), body.reason).await
253}
254
255#[cfg(feature = "auth")]
256pub fn create_protected_journal_workflow_routes<A: AuthMiddleware + Send + Sync + 'static>(
258 service: Arc<JournalWorkflowService>,
259 auth: Arc<A>,
260) -> Router {
261 use axum::{middleware, response::IntoResponse};
262
263 let auth_layer = auth.clone();
264 Router::new()
265 .route("/journals/:id/submit", post(submit))
266 .route("/journals/:id/approve", post(approve_protected))
267 .route("/journals/:id/reject", post(reject_protected))
268 .route("/journals/:id/void", post(void_protected))
269 .with_state(service)
270 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
271 let auth = auth_layer.clone();
272 async move {
273 let token = req
274 .headers()
275 .get(axum::http::header::AUTHORIZATION)
276 .and_then(|h| h.to_str().ok())
277 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
278 .unwrap_or("");
279 match auth.authenticate(token).await {
280 Ok(ctx) => {
281 req.extensions_mut().insert(ctx);
282 next.run(req).await
283 }
284 Err(_) => (
285 axum::http::StatusCode::UNAUTHORIZED,
286 axum::Json(serde_json::json!({
287 "success": false, "error": "unauthorized", "message": "Authentication required"
288 })),
289 ).into_response(),
290 }
291 }
292 }))
293}