backbone_accounting/presentation/http/
posting_handler.rs1use 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 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
75async 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
87fn posting_request_from_dto(dto: PostingRequestDto) -> PostingRequest {
89 PostingRequest {
90 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
123async 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
157pub 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#[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 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")]
194pub 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}