Skip to main content

backbone_accounting/presentation/http/
journal_workflow_handler.rs

1//! Non-CRUD HTTP surface for the manual-journal approval workflow.
2//!
3//! Hand-authored (user-owned; see `metaphor.codegen.yaml`). Wraps `JournalWorkflowService`:
4//!   POST /journals/:id/submit                                      (no body)
5//!   POST /journals/:id/approve   body { approved_by }
6//!   POST /journals/:id/reject    body { reason, rejected_by? }
7//!   POST /journals/:id/void      body { voided_by?, reason }
8//!
9//! `approve` posts the journal to the ledger; `void` posts a reversal. Both flow through the
10//! audited `PostingService` core (FOR UPDATE per-account lock, idempotency, immutable ledger).
11//!
12//! Tenancy (ADR-0029): the wire carries no tenant. The composing service's decorator scopes
13//! every read and write from the request's own scope.
14
15use std::sync::Arc;
16
17use axum::{
18    extract::Path, extract::Query, extract::State, response::IntoResponse, routing::post, Json,
19    Router,
20};
21use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24use crate::application::service::journal_workflow_service::{
25    JournalWorkflowError, JournalWorkflowService,
26};
27
28#[derive(Debug, Deserialize)]
29pub struct CompanyQuery {
30}
31
32#[derive(Debug, Deserialize)]
33pub struct ApproveBody {
34    pub approved_by: Option<Uuid>,
35}
36
37#[derive(Debug, Deserialize)]
38pub struct RejectBody {
39    pub reason: String,
40    pub rejected_by: Option<Uuid>,
41}
42
43#[derive(Debug, Deserialize)]
44pub struct VoidBody {
45    pub voided_by: Option<Uuid>,
46    pub reason: String,
47}
48
49#[derive(Debug, Serialize)]
50struct WorkflowResponse {
51    success: bool,
52    journal_id: Uuid,
53    status: &'static str,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    post_id: Option<Uuid>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    idempotent_reuse: Option<bool>,
58}
59
60fn err_response(e: JournalWorkflowError) -> (axum::http::StatusCode, Json<serde_json::Value>) {
61    let status = axum::http::StatusCode::from_u16(e.http_status())
62        .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
63    (
64        status,
65        Json(serde_json::json!({
66            "success": false,
67            "error": e.code(),
68            "message": e.to_string(),
69        })),
70    )
71}
72
73async fn submit(
74    State(svc): State<Arc<JournalWorkflowService>>,
75    Path(id): Path<Uuid>,
76    Query(_q): Query<CompanyQuery>,
77) -> impl IntoResponse {
78    match svc.submit(id).await {
79        Ok(()) => Json(WorkflowResponse {
80            success: true,
81            journal_id: id,
82            status: "pending_approval",
83            post_id: None,
84            idempotent_reuse: None,
85        })
86        .into_response(),
87        Err(e) => err_response(e).into_response(),
88    }
89}
90
91async fn approve(
92    State(svc): State<Arc<JournalWorkflowService>>,
93    Path(id): Path<Uuid>,
94    Json(body): Json<ApproveBody>,
95) -> impl IntoResponse {
96    run_approve(svc, id, body.approved_by).await
97}
98
99async fn reject(
100    State(svc): State<Arc<JournalWorkflowService>>,
101    Path(id): Path<Uuid>,
102    Json(body): Json<RejectBody>,
103) -> impl IntoResponse {
104    run_reject(svc, id, body.reason, body.rejected_by).await
105}
106
107async fn void(
108    State(svc): State<Arc<JournalWorkflowService>>,
109    Path(id): Path<Uuid>,
110    Json(body): Json<VoidBody>,
111) -> impl IntoResponse {
112    run_void(svc, id, body.voided_by, body.reason).await
113}
114
115/// Shared execution for approve (open + protected variants).
116async fn run_approve(
117    svc: Arc<JournalWorkflowService>,
118    id: Uuid,
119    approved_by: Option<Uuid>,
120) -> axum::response::Response {
121    match svc.approve(id, approved_by).await {
122        Ok(r) => Json(WorkflowResponse {
123            success: true,
124            journal_id: r.journal_id,
125            status: "posted",
126            post_id: Some(r.post_id),
127            idempotent_reuse: Some(r.idempotent_reuse),
128        })
129        .into_response(),
130        Err(e) => err_response(e).into_response(),
131    }
132}
133
134/// Shared execution for reject.
135async fn run_reject(
136    svc: Arc<JournalWorkflowService>,
137    id: Uuid,
138    reason: String,
139    rejected_by: Option<Uuid>,
140) -> axum::response::Response {
141    match svc.reject(id, reason, rejected_by).await {
142        Ok(()) => Json(WorkflowResponse {
143            success: true,
144            journal_id: id,
145            status: "rejected",
146            post_id: None,
147            idempotent_reuse: None,
148        })
149        .into_response(),
150        Err(e) => err_response(e).into_response(),
151    }
152}
153
154/// Shared execution for void.
155async fn run_void(
156    svc: Arc<JournalWorkflowService>,
157    id: Uuid,
158    voided_by: Option<Uuid>,
159    reason: String,
160) -> axum::response::Response {
161    match svc.void(id, voided_by, reason).await {
162        Ok(r) => Json(WorkflowResponse {
163            success: true,
164            journal_id: id,
165            status: "voided",
166            post_id: Some(r.post_id),
167            idempotent_reuse: Some(r.idempotent_reuse),
168        })
169        .into_response(),
170        Err(e) => err_response(e).into_response(),
171    }
172}
173
174/// Route composer for the manual-journal workflow endpoints.
175pub fn create_journal_workflow_routes(service: Arc<JournalWorkflowService>) -> Router {
176    Router::new()
177        .route("/journals/:id/submit", post(submit))
178        .route("/journals/:id/approve", post(approve))
179        .route("/journals/:id/reject", post(reject))
180        .route("/journals/:id/void", post(void))
181        .with_state(service)
182}
183
184// =============================================================================
185// Authenticated variant — derives approve/reject/void actors from the principal
186// =============================================================================
187//
188// `company_id` still comes from the body (AuthContext carries no tenant); it is the legacy
189// tenancy twin (ADR-0029) — the module keys no statement on it. Cross-tenant isolation is owned
190// by the composing service's tenancy decorator. The actor fields
191// (`approved_by`/`rejected_by`/`voided_by`) are taken from the verified `AuthContext`, making
192// the audit trail non-repudiable.
193
194#[cfg(feature = "auth")]
195use axum::Extension;
196
197#[cfg(feature = "auth")]
198use backbone_auth::{middleware::AuthContext, AuthMiddleware};
199
200#[cfg(feature = "auth")]
201fn principal(auth: &AuthContext) -> Option<Uuid> {
202    Uuid::parse_str(&auth.user_id).ok()
203}
204
205#[cfg(feature = "auth")]
206async fn approve_protected(
207    State(svc): State<Arc<JournalWorkflowService>>,
208    Extension(auth): Extension<AuthContext>,
209    Path(id): Path<Uuid>,
210    Json(body): Json<ApproveBody>,
211) -> impl IntoResponse {
212    run_approve(svc, id, principal(&auth)).await
213}
214
215#[cfg(feature = "auth")]
216async fn reject_protected(
217    State(svc): State<Arc<JournalWorkflowService>>,
218    Extension(auth): Extension<AuthContext>,
219    Path(id): Path<Uuid>,
220    Json(body): Json<RejectBody>,
221) -> impl IntoResponse {
222    run_reject(svc, id, body.reason, principal(&auth)).await
223}
224
225#[cfg(feature = "auth")]
226async fn void_protected(
227    State(svc): State<Arc<JournalWorkflowService>>,
228    Extension(auth): Extension<AuthContext>,
229    Path(id): Path<Uuid>,
230    Json(body): Json<VoidBody>,
231) -> impl IntoResponse {
232    run_void(svc, id, principal(&auth), body.reason).await
233}
234
235#[cfg(feature = "auth")]
236/// Authenticated workflow routes: actors derived from the principal. Requires the `auth` feature.
237pub fn create_protected_journal_workflow_routes<A: AuthMiddleware + Send + Sync + 'static>(
238    service: Arc<JournalWorkflowService>,
239    auth: Arc<A>,
240) -> Router {
241    use axum::{middleware, response::IntoResponse};
242
243    let auth_layer = auth.clone();
244    Router::new()
245        .route("/journals/:id/submit", post(submit))
246        .route("/journals/:id/approve", post(approve_protected))
247        .route("/journals/:id/reject", post(reject_protected))
248        .route("/journals/:id/void", post(void_protected))
249        .with_state(service)
250        .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
251            let auth = auth_layer.clone();
252            async move {
253                let token = req
254                    .headers()
255                    .get(axum::http::header::AUTHORIZATION)
256                    .and_then(|h| h.to_str().ok())
257                    .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
258                    .unwrap_or("");
259                match auth.authenticate(token).await {
260                    Ok(ctx) => {
261                        req.extensions_mut().insert(ctx);
262                        next.run(req).await
263                    }
264                    Err(_) => (
265                        axum::http::StatusCode::UNAUTHORIZED,
266                        axum::Json(serde_json::json!({
267                            "success": false, "error": "unauthorized", "message": "Authentication required"
268                        })),
269                    ).into_response(),
270                }
271            }
272        }))
273}