Skip to main content

backbone_accounting/presentation/http/
reconcile_handler.rs

1//! Reconciliation verb HTTP surface — hand-authored (NOT generated).
2//!
3//! Three routes over the `ReconcileWriteService` pool verbs:
4//! - `POST /accounting/reconcile` — create (or grow) a debit↔credit edge
5//! - `POST /accounting/unreconcile/:partial_id` — the side-effecting unlink (reverses
6//!   generated moves, repairs flags/groups)
7//! - `GET  /accounting/reconciliation-groups/:line_id` — the matching-group read
8//!
9//! The graph tables themselves expose NO CRUD routes anywhere (`enabled: false` +
10//! `guarded_routes` mounts none) — this file IS the only write surface. Hosts mount the
11//! `auth`-protected variants; the accountant role gate on unlink is a HOST concern
12//! (module-level default-deny is the composition's job — see the ADR).
13//!
14//! This file is user-owned (see `metaphor.codegen.yaml`) and survives regeneration.
15//!
16//! Tenancy (ADR-0029): the `company_id` each request carries is the legacy twin — the wire
17//! shapes keep it so unstripped callers compile and run unchanged; the module keys no
18//! statement on it and the composing service's tenancy decorator scopes every statement.
19
20use std::sync::Arc;
21
22use axum::{
23    extract::{Path, Query, State},
24    http::StatusCode,
25    response::IntoResponse,
26    routing::{get, post},
27    Json, Router,
28};
29use rust_decimal::Decimal;
30use serde::{Deserialize, Serialize};
31use uuid::Uuid;
32
33use crate::application::service::reconcile_write_service::ReconcileWriteService;
34use crate::domain::reconcile_graph::{
35    EdgeOutcome, LineLocator, PairRequest, ReconcileError, ORIGIN_MANUAL,
36};
37
38// =============================================================================
39// DTOs
40// =============================================================================
41
42#[derive(Debug, Deserialize)]
43pub struct ReconcileLineDto {
44    /// Producer identity of the journal line, e.g. "payment" / "order" / "expense".
45    pub source_type: String,
46    /// Producer identity value (payment id, invoice ref, …).
47    pub source_id: Uuid,
48    /// The reconcilable control account the line sits on.
49    pub account_id: Uuid,
50    /// True when the line belongs to a reversal journal.
51    #[serde(default)]
52    pub reversing: bool,
53}
54
55#[derive(Debug, Deserialize)]
56pub struct ReconcilePairRequestDto {
57    pub debit: ReconcileLineDto,
58    pub credit: ReconcileLineDto,
59    /// Company-currency amount requested; clamped to the smaller residual.
60    pub amount: Decimal,
61    /// settlement | clearing | manual (defaults to manual).
62    #[serde(default = "default_origin")]
63    pub origin: String,
64}
65
66fn default_origin() -> String {
67    ORIGIN_MANUAL.to_string()
68}
69
70#[derive(Debug, Serialize)]
71pub struct ReconcileEdgeAckDto {
72    /// `null` when the request clamped to zero — no edge exists (on-account remainder).
73    pub partial_id: Option<Uuid>,
74    pub applied: Decimal,
75    pub full_reconcile_id: Option<Uuid>,
76}
77
78#[derive(Debug, Deserialize)]
79pub struct UnreconcileBody {
80}
81
82#[derive(Debug, Deserialize)]
83pub struct CompanyQuery {
84}
85
86#[derive(Debug, Serialize)]
87pub struct MatchingGroupDto {
88    pub label: String,
89    pub full_reconcile_id: Option<Uuid>,
90    pub line_ids: Vec<Uuid>,
91    pub partial_ids: Vec<Uuid>,
92    pub residuals: Vec<ResidualDto>,
93}
94
95#[derive(Debug, Serialize)]
96pub struct ResidualDto {
97    pub line_id: Uuid,
98    pub residual: Decimal,
99}
100
101#[derive(Debug, Serialize)]
102pub struct ReconcileErrorDto {
103    pub code: String,
104    pub message: String,
105}
106
107// =============================================================================
108// Mapping
109// =============================================================================
110
111fn locator_from_dto(dto: ReconcileLineDto) -> LineLocator {
112    let l = LineLocator::new(&dto.source_type, dto.source_id, dto.account_id);
113    if dto.reversing {
114        l.reversing()
115    } else {
116        l
117    }
118}
119
120fn ack_from_outcome(o: EdgeOutcome) -> ReconcileEdgeAckDto {
121    ReconcileEdgeAckDto {
122        partial_id: o.partial_id,
123        applied: o.applied,
124        full_reconcile_id: o.full_reconcile_id,
125    }
126}
127
128fn error_response(e: &ReconcileError) -> axum::response::Response {
129    let status = StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
130    (
131        status,
132        Json(ReconcileErrorDto {
133            code: e.code().to_string(),
134            message: e.to_string(),
135        }),
136    )
137        .into_response()
138}
139
140// ── Tenant consistency ────────────────────────────────────────────────────────
141//
142// The module keys no statement on the request's `company_id` (ADR-0029) — it is the legacy
143// twin, and cross-tenant isolation is the composing service's tenancy decorator. The request's
144// company must still agree with the caller's authenticated identity when one is established:
145// either the legacy request company scope (backbone-auth's `company_auth` wraps every request
146// in `with_company_scope(token.company_id)`) or the ambient org scope's legacy company id.
147// Without this check an authenticated tenant could name ANY company in the body and reshape
148// its books through the legacy seams. With no scope at all (unit tests, trusted internal
149// hosts) the check is skipped — the module keeps its standalone shape.
150
151
152
153
154// =============================================================================
155// Handlers
156// =============================================================================
157
158async fn reconcile_handler(
159    State(service): State<Arc<ReconcileWriteService>>,
160    Json(dto): Json<ReconcilePairRequestDto>,
161) -> impl IntoResponse {
162    let req = PairRequest {
163        debit: locator_from_dto(dto.debit),
164        credit: locator_from_dto(dto.credit),
165        amount: dto.amount,
166        origin: dto.origin,
167        actor: None,
168    };
169    match service.reconcile_pair(&req).await {
170        Ok(o) => (StatusCode::OK, Json(ack_from_outcome(o))).into_response(),
171        Err(e) => error_response(&e),
172    }
173}
174
175async fn unreconcile_handler(
176    State(service): State<Arc<ReconcileWriteService>>,
177    Path(partial_id): Path<Uuid>,
178    Json(_body): Json<UnreconcileBody>,
179) -> impl IntoResponse {
180    match service.unreconcile(partial_id, None).await {
181        Ok(()) => StatusCode::NO_CONTENT.into_response(),
182        Err(e) => error_response(&e),
183    }
184}
185
186async fn matching_group_handler(
187    State(service): State<Arc<ReconcileWriteService>>,
188    Path(line_id): Path<Uuid>,
189    Query(_q): Query<CompanyQuery>,
190) -> impl IntoResponse {
191    match service.matching_group(line_id).await {
192        Ok(g) => (
193            StatusCode::OK,
194            Json(MatchingGroupDto {
195                label: g.label,
196                full_reconcile_id: g.full_reconcile_id,
197                line_ids: g.line_ids,
198                partial_ids: g.partial_ids,
199                residuals: g
200                    .residuals
201                    .into_iter()
202                    .map(|(line_id, residual)| ResidualDto { line_id, residual })
203                    .collect(),
204            }),
205        )
206            .into_response(),
207        Err(e) => error_response(&e),
208    }
209}
210
211// =============================================================================
212// Routes
213// =============================================================================
214
215/// The reconciliation verb routes (unauthenticated variant — for tests and trusted
216/// internal hosts; hosts with an auth middleware wrap these handlers with their own
217/// layers and principal-derived `actor`).
218pub fn create_reconcile_verb_routes(service: Arc<ReconcileWriteService>) -> Router {
219    Router::new()
220        .route("/accounting/reconcile", post(reconcile_handler))
221        .route(
222            "/accounting/unreconcile/:partial_id",
223            post(unreconcile_handler),
224        )
225        .route(
226            "/accounting/reconciliation-groups/:line_id",
227            get(matching_group_handler),
228        )
229        .with_state(service)
230}