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 {}
6//!   POST /journals/:id/reject    body { reason }
7//!   POST /journals/:id/void      body { reason }
8//!
9//! The person approving, rejecting or voiding is the authenticated principal, read from the
10//! `OrgContext` the host's org guard inserts, never a field of the body: a body could name
11//! anyone as the approver. A request without that context is refused with 401. The bodies
12//! still accept the old `approved_by` / `rejected_by` / `voided_by` fields so existing
13//! callers keep working, and ignore them.
14//!
15//! `approve` posts the journal to the ledger; `void` posts a reversal. Both flow through the
16//! audited `PostingService` core (FOR UPDATE per-account lock, idempotency, immutable ledger).
17//!
18//! Tenancy (ADR-0029): the wire carries no tenant. The composing service's decorator scopes
19//! every read and write from the request's own scope.
20
21use 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    /// Ignored: the approver is the authenticated principal.
43    pub approved_by: Option<Uuid>,
44}
45
46#[derive(Debug, Deserialize)]
47pub struct RejectBody {
48    pub reason: String,
49    /// Ignored: the rejecter is the authenticated principal.
50    pub rejected_by: Option<Uuid>,
51}
52
53#[derive(Debug, Deserialize)]
54pub struct VoidBody {
55    /// Ignored: the voider is the authenticated principal.
56    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
93/// Shared execution for submit: the submitter is recorded for the self-approval rule.
94async 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
108/// The person acting: the org guard's authenticated principal.
109fn 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
140/// Shared execution for approve (open + protected variants).
141async 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
159/// Shared execution for reject.
160async 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
179/// Shared execution for void.
180async 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
199/// Route composer for the manual-journal workflow endpoints. Mount it behind the host's org
200/// guard: approve, reject and void read the acting user from its `OrgContext`.
201pub 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// =============================================================================
211// Authenticated variant — derives approve/reject/void actors from the principal
212// =============================================================================
213//
214// `company_id` still comes from the body (AuthContext carries no tenant); it is the legacy
215// tenancy twin (ADR-0029) — the module keys no statement on it. Cross-tenant isolation is owned
216// by the composing service's tenancy decorator. The actor fields
217// (`approved_by`/`rejected_by`/`voided_by`) are taken from the verified `AuthContext`, making
218// the audit trail non-repudiable.
219
220#[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")]
271/// Authenticated workflow routes: actors derived from the principal. Requires the `auth` feature.
272pub 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}