Skip to main content

backbone_accounting/presentation/http/
posting_handler.rs

1//! Non-CRUD HTTP surface for the GL-posting contract: `POST /accounting/posts`.
2//!
3//! Hand-authored (user-owned; see `metaphor.codegen.yaml`). Wraps `PostingService` — the
4//! inbound port a producer (billing/selling/payments) calls to record a balanced entry.
5
6use std::sync::Arc;
7
8use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Json, Router};
9use chrono::NaiveDate;
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use crate::application::service::posting_service::{PostingLine, PostingRequest, PostingService};
15
16fn default_idr() -> String {
17    "IDR".to_string()
18}
19fn default_original() -> String {
20    "original".to_string()
21}
22
23#[derive(Debug, Deserialize)]
24pub struct PostingLineDto {
25    pub account_id: Uuid,
26    #[serde(default)]
27    pub debit: Decimal,
28    #[serde(default)]
29    pub credit: Decimal,
30    pub party_type: Option<String>,
31    pub party_id: Option<Uuid>,
32    pub cost_center_id: Option<Uuid>,
33    pub project_id: Option<Uuid>,
34    pub department_id: Option<Uuid>,
35    pub description: Option<String>,
36}
37
38#[derive(Debug, Deserialize)]
39pub struct PostingRequestDto {
40    pub branch_id: Option<Uuid>,
41    pub source_type: String,
42    pub source_id: Uuid,
43    pub source_reference: Option<String>,
44    pub posting_date: NaiveDate,
45    #[serde(default = "default_idr")]
46    pub currency: String,
47    #[serde(default = "default_original")]
48    pub posting_type: String,
49    pub reverses_post_id: Option<Uuid>,
50    pub description: Option<String>,
51    /// Ignored: the poster is the authenticated principal. Still accepted so existing
52    /// callers keep working.
53    pub posted_by: Option<Uuid>,
54    #[serde(default)]
55    pub idempotency_key: Option<String>,
56    #[serde(default)]
57    pub lines: Vec<PostingLineDto>,
58}
59
60#[derive(Debug, Serialize)]
61pub struct PostingResponse {
62    pub success: bool,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub post_id: Option<Uuid>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub journal_id: Option<Uuid>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub posting_status: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub idempotent_reuse: Option<bool>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub error_code: Option<String>,
73}
74
75/// The person posting is the org guard's authenticated principal, never a field of the body,
76/// which could name anyone as the ledger's author. A request without the `OrgContext` the
77/// host's org guard inserts is refused with 401.
78async fn post_handler(
79    State(service): State<Arc<PostingService>>,
80    org: backbone_auth::org::OrgContext,
81    Json(dto): Json<PostingRequestDto>,
82) -> impl IntoResponse {
83    let posted_by = Uuid::parse_str(&org.user_id).ok();
84    run_post(service, posting_request_from_dto(dto), posted_by).await
85}
86
87/// Map the wire DTO to the domain `PostingRequest`.
88fn posting_request_from_dto(dto: PostingRequestDto) -> PostingRequest {
89    PostingRequest {
90        // The wire no longer names a tenant; the posting request still carries the legacy
91        // company twin for unstripped consumers, so it comes from the ambient org scope.
92        company_id: backbone_orm::org_scope::current_org_scope()
93            .and_then(|s| s.legacy_company_id())
94            .unwrap_or_default(),
95        branch_id: dto.branch_id,
96        source_type: dto.source_type,
97        source_id: dto.source_id,
98        source_reference: dto.source_reference,
99        posting_date: dto.posting_date,
100        currency: dto.currency,
101        posting_type: dto.posting_type,
102        reverses_post_id: dto.reverses_post_id,
103        description: dto.description,
104        lines: dto
105            .lines
106            .into_iter()
107            .map(|l| PostingLine {
108                account_id: l.account_id,
109                debit: l.debit,
110                credit: l.credit,
111                party_type: l.party_type,
112                party_id: l.party_id,
113                cost_center_id: l.cost_center_id,
114                project_id: l.project_id,
115                department_id: l.department_id,
116                description: l.description,
117            })
118            .collect(),
119        idempotency_key: dto.idempotency_key,
120    }
121}
122
123/// Shared post execution + response mapping for the open and protected handlers.
124async fn run_post(
125    service: Arc<PostingService>,
126    req: PostingRequest,
127    posted_by: Option<Uuid>,
128) -> axum::response::Response {
129    match service.post(req, posted_by).await {
130        Ok(r) => (
131            StatusCode::OK,
132            Json(PostingResponse {
133                success: true,
134                post_id: Some(r.post_id),
135                journal_id: Some(r.journal_id),
136                posting_status: Some(r.posting_status),
137                idempotent_reuse: Some(r.idempotent_reuse),
138                error_code: None,
139            }),
140        )
141            .into_response(),
142        Err(e) => (
143            StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::UNPROCESSABLE_ENTITY),
144            Json(PostingResponse {
145                success: false,
146                post_id: None,
147                journal_id: None,
148                posting_status: None,
149                idempotent_reuse: None,
150                error_code: Some(e.code().to_string()),
151            }),
152        )
153            .into_response(),
154    }
155}
156
157/// Route: `POST /accounting/posts` — the inbound GL-posting endpoint. Mount it behind the
158/// host's org guard: the poster is read from its `OrgContext`.
159pub fn create_posting_routes(service: Arc<PostingService>) -> Router {
160    Router::new()
161        .route("/accounting/posts", post(post_handler))
162        .with_state(service)
163}
164
165// =============================================================================
166// Authenticated variant — derives `posted_by` from the verified principal
167// =============================================================================
168//
169// Use this in hosts that mount an auth middleware: `posted_by` is taken from the `AuthContext`
170// (non-repudiable audit trail) rather than trusted from the request body. `company_id` still comes
171// from the body (AuthContext carries no tenant); it is the legacy tenancy twin (ADR-0029) — the
172// module itself keys no statement on it. Cross-tenant isolation is owned by the composing
173// service's tenancy decorator, which scopes every statement through the ambient org scope.
174
175#[cfg(feature = "auth")]
176use axum::Extension;
177
178#[cfg(feature = "auth")]
179use backbone_auth::{middleware::AuthContext, AuthMiddleware};
180
181#[cfg(feature = "auth")]
182async fn post_handler_protected(
183    State(service): State<Arc<PostingService>>,
184    Extension(auth): Extension<AuthContext>,
185    Json(dto): Json<PostingRequestDto>,
186) -> impl IntoResponse {
187    let req = posting_request_from_dto(dto);
188    // The auditor is the authenticated principal, not a body field.
189    let posted_by = uuid::Uuid::parse_str(&auth.user_id).ok();
190    run_post(service, req, posted_by).await
191}
192
193#[cfg(feature = "auth")]
194/// Authenticated route: `POST /accounting/posts` with `posted_by` derived from the principal.
195/// Requires the `auth` feature; wrap with a host that supplies a bearer token.
196pub fn create_protected_posting_routes<A: AuthMiddleware + Send + Sync + 'static>(
197    service: Arc<PostingService>,
198    auth: Arc<A>,
199) -> Router {
200    use axum::{middleware, response::IntoResponse, Extension};
201
202    let auth_layer = auth.clone();
203    Router::new()
204        .route("/accounting/posts", post(post_handler_protected))
205        .with_state(service)
206        .layer(middleware::from_fn(
207            move |mut req: axum::extract::Request, next: axum::middleware::Next| {
208                let auth = auth_layer.clone();
209                async move {
210                    let token = req
211                        .headers()
212                        .get(axum::http::header::AUTHORIZATION)
213                        .and_then(|h| h.to_str().ok())
214                        .and_then(|raw| {
215                            raw.strip_prefix("Bearer ")
216                                .or_else(|| raw.strip_prefix("bearer "))
217                        })
218                        .unwrap_or("");
219                    match auth.authenticate(token).await {
220                        Ok(ctx) => {
221                            req.extensions_mut().insert(ctx);
222                            next.run(req).await
223                        }
224                        Err(_) => (
225                            axum::http::StatusCode::UNAUTHORIZED,
226                            axum::Json(serde_json::json!({
227                                "success": false,
228                                "error": "unauthorized",
229                                "message": "Authentication required"
230                            })),
231                        )
232                            .into_response(),
233                    }
234                }
235            },
236        ))
237}