Skip to main content

backbone_accounting/presentation/http/
accounting_ops_handler.rs

1//! Non-CRUD HTTP surface for accounting operations: bank reconciliation + the
2//! fiscal period lifecycle.
3//!
4//! Hand-authored (user-owned; see `metaphor.codegen.yaml`).
5//!   POST /accounting/reconcile                       (body = ReconcileRequest)
6//!   POST /accounting/periods/:period_id/close        (body = { retained_earnings_account_id })
7//!   POST /accounting/periods/:period_id/lock         (body = { reason })
8//!   POST /accounting/periods/:period_id/reopen       (body = { reason })
9//!
10//! A period's status moves only through these verbs; generic writes refuse
11//! it. The person acting is the authenticated principal (`OrgContext`), never
12//! a field of the body, so the trail on the period names who really acted. A
13//! request without one is refused before the handler runs. Which role may
14//! call which verb is the composing service's gate.
15//!
16//! Tenancy (ADR-0029): the wire bodies carry no tenant field. The composing service's tenancy
17//! decorator scopes every read and write from the request's own scope, so a tenant sent in the
18//! body could only ever agree with it or contradict it — and a field that cannot change the
19//! answer is a field that invites someone to think it can.
20
21use std::sync::Arc;
22
23use axum::{
24    extract::{Path, State},
25    http::StatusCode,
26    response::IntoResponse,
27    routing::post,
28    Json, Router,
29};
30use serde::Deserialize;
31use uuid::Uuid;
32
33use crate::application::service::bank_reconciliation_service::{
34    BankReconciliationService, ReconcileRequest,
35};
36use crate::application::service::period_close_service::{PeriodCloseError, PeriodCloseService};
37use backbone_auth::org::OrgContext;
38
39// ── Reconciliation ────────────────────────────────────────────────────────────
40async fn reconcile(
41    State(svc): State<Arc<BankReconciliationService>>,
42    Json(req): Json<ReconcileRequest>,
43) -> impl IntoResponse {
44    match svc.reconcile(req).await {
45        Ok(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())),
46        Err(e) => (
47            StatusCode::UNPROCESSABLE_ENTITY,
48            Json(serde_json::json!({ "success": false, "error": e.to_string() })),
49        ),
50    }
51}
52
53pub fn create_bank_reconciliation_routes(service: Arc<BankReconciliationService>) -> Router {
54    Router::new()
55        .route("/accounting/reconcile", post(reconcile))
56        .with_state(service)
57}
58
59// ── Fiscal period lifecycle ───────────────────────────────────────────────────
60#[derive(Debug, Deserialize)]
61pub struct ClosePeriodBody {
62    pub retained_earnings_account_id: Uuid,
63}
64
65/// The body of a lock or reopen: why. A blank reason is refused.
66#[derive(Debug, Deserialize)]
67pub struct PeriodReasonBody {
68    #[serde(default)]
69    pub reason: String,
70}
71
72fn actor(org: &OrgContext) -> Option<Uuid> {
73    Uuid::parse_str(&org.user_id).ok()
74}
75
76/// 404 for a period that is not there, 409 for a move the period's current
77/// state does not allow, 422 for a request that is incomplete or a closing
78/// entry the posting rules refuse, 500 otherwise.
79fn period_error(e: PeriodCloseError) -> (StatusCode, Json<serde_json::Value>) {
80    let status = match &e {
81        PeriodCloseError::PeriodNotFound(_) => StatusCode::NOT_FOUND,
82        PeriodCloseError::AlreadyClosed
83        | PeriodCloseError::CloseInProgress
84        | PeriodCloseError::NotClosed { .. }
85        | PeriodCloseError::Locked => StatusCode::CONFLICT,
86        PeriodCloseError::ReasonRequired | PeriodCloseError::Posting(_) => StatusCode::UNPROCESSABLE_ENTITY,
87        PeriodCloseError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
88    };
89    (status, Json(serde_json::json!({ "success": false, "error": e.to_string() })))
90}
91
92async fn close_period(
93    State(svc): State<Arc<PeriodCloseService>>,
94    org: OrgContext,
95    Path(period_id): Path<Uuid>,
96    Json(body): Json<ClosePeriodBody>,
97) -> impl IntoResponse {
98    match svc
99        .close_period_as(period_id, body.retained_earnings_account_id, actor(&org))
100        .await
101    {
102        Ok(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())),
103        Err(e) => period_error(e),
104    }
105}
106
107async fn lock_period(
108    State(svc): State<Arc<PeriodCloseService>>,
109    org: OrgContext,
110    Path(period_id): Path<Uuid>,
111    Json(body): Json<PeriodReasonBody>,
112) -> impl IntoResponse {
113    match svc.lock_period(period_id, actor(&org), &body.reason).await {
114        Ok(()) => (StatusCode::OK, Json(serde_json::json!({ "success": true, "period_id": period_id, "status": "locked" }))),
115        Err(e) => period_error(e),
116    }
117}
118
119async fn reopen_period(
120    State(svc): State<Arc<PeriodCloseService>>,
121    org: OrgContext,
122    Path(period_id): Path<Uuid>,
123    Json(body): Json<PeriodReasonBody>,
124) -> impl IntoResponse {
125    match svc.reopen_period(period_id, actor(&org), &body.reason).await {
126        Ok(()) => (StatusCode::OK, Json(serde_json::json!({ "success": true, "period_id": period_id, "status": "open" }))),
127        Err(e) => period_error(e),
128    }
129}
130
131/// The routine close. Lock and reopen live in
132/// [`create_period_finality_routes`], so the two can sit behind different
133/// roles.
134pub fn create_period_close_routes(service: Arc<PeriodCloseService>) -> Router {
135    Router::new()
136        .route("/accounting/periods/:period_id/close", post(close_period))
137        .with_state(service)
138}
139
140/// The lock and reopen verbs: the ones that make a period final or undo a
141/// close. A separate router so the composing service can put them behind a
142/// stricter role than the routine close.
143pub fn create_period_finality_routes(service: Arc<PeriodCloseService>) -> Router {
144    Router::new()
145        .route("/accounting/periods/:period_id/lock", post(lock_period))
146        .route("/accounting/periods/:period_id/reopen", post(reopen_period))
147        .with_state(service)
148}