Skip to main content

backbone_accounting/domain/
gl_posting.rs

1//! GL-posting contract types (domain layer).
2//!
3//! These are the domain shapes shared by the `PostingRepository` port
4//! (`domain/repositories/posting_repository.rs`), the pure validation rules
5//! (`domain/services/posting_rules.rs`), and the application `PostingService`.
6//! Living in the domain layer means neither the port nor the rules depend on the
7//! application or infrastructure layers — and `PostingError` carries no `sqlx`
8//! type, so the domain stays persistence-agnostic.
9//!
10//! Tenancy (ADR-0029): the module is tenant-agnostic. The `company_id` fields in these
11//! contract shapes are the documented legacy twin — unstripped producers still pass one and
12//! still get correct behavior. It is carried through to host seams and event payloads only;
13//! the persistence adapter scopes every statement by the ambient org scope, never by this
14//! field.
15
16use chrono::{DateTime, NaiveDate, Utc};
17use rust_decimal::Decimal;
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21use crate::domain::repositories::budget_control::BudgetBreach;
22
23/// One debit/credit line of a posting request. Exactly one of `debit`/`credit` is > 0.
24#[derive(Debug, Clone)]
25pub struct PostingLine {
26    pub account_id: Uuid,
27    pub debit: Decimal,
28    pub credit: Decimal,
29    pub party_type: Option<String>, // "customer" | "supplier" | "employee"
30    pub party_id: Option<Uuid>,
31    pub cost_center_id: Option<Uuid>,
32    pub project_id: Option<Uuid>,
33    pub department_id: Option<Uuid>,
34    pub description: Option<String>,
35}
36
37/// A request to record a balanced set of lines in the GL (the inbound contract shape).
38#[derive(Debug, Clone)]
39pub struct PostingRequest {
40    /// The legacy tenancy twin (ADR-0029) — see the module-level note. Unstripped producers
41    /// keep passing it; nothing keys a statement on it.
42    pub company_id: Uuid,
43    pub branch_id: Option<Uuid>,
44    pub source_type: String, // posting_source_type: order|payment|settlement|refund|expense|inventory|manual
45    pub source_id: Uuid,
46    pub source_reference: Option<String>,
47    pub posting_date: NaiveDate,
48    pub currency: String,
49    pub posting_type: String, // "original" | "reversal"
50    pub reverses_post_id: Option<Uuid>,
51    pub description: Option<String>,
52    pub lines: Vec<PostingLine>,
53    /// The REAL dedup key when set: two posts with the same `(company_id, idempotency_key)` collapse
54    /// to one, and the producer may reuse `source_id` across its several posts. When `None`, dedup
55    /// falls back to the tuple `(company_id, source_type, source_id, posting_type)`. The `company_id`
56    /// element of those tuples is the caller's own identity (the legacy twin, ADR-0029); the module
57    /// scopes dedup by the ambient org scope instead — on a decorated deployment the decorator's
58    /// per-unit unique index keeps the tuple per-org.
59    pub idempotency_key: Option<String>,
60}
61
62impl PostingRequest {
63    /// Convenience constructor for an original posting. `company_id` is the legacy tenancy twin
64    /// (ADR-0029) — kept so unstripped producers compile and run unchanged.
65    pub fn original(
66        company_id: Uuid,
67        source_type: &str,
68        source_id: Uuid,
69        posting_date: NaiveDate,
70    ) -> Self {
71        Self {
72            company_id,
73            branch_id: None,
74            source_type: source_type.to_string(),
75            source_id,
76            source_reference: None,
77            posting_date,
78            currency: "IDR".to_string(),
79            posting_type: "original".to_string(),
80            reverses_post_id: None,
81            description: None,
82            lines: Vec::new(),
83            idempotency_key: None,
84        }
85    }
86
87    /// Set the idempotency key (the real per-post dedup identity).
88    pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
89        self.idempotency_key = Some(key.into());
90        self
91    }
92}
93
94/// Outcome of a successful post.
95#[derive(Debug, Clone)]
96pub struct PostingResult {
97    pub post_id: Uuid,
98    pub journal_id: Uuid,
99    pub posting_status: String,
100    /// True when an existing posted entry was returned instead of writing a new one.
101    pub idempotent_reuse: bool,
102}
103
104/// Typed posting failure. `code()` is the stable error string asserted by the golden cases.
105/// Deliberately carries NO `sqlx::Error` — the domain layer is persistence-agnostic; the
106/// infrastructure adapter maps storage errors to `PostingError::Internal`.
107#[derive(Debug)]
108pub enum PostingError {
109    TooFewLines,
110    Unbalanced,
111    /// A line's debit/credit are not valid: negative, both non-zero, or both zero.
112    /// A clean line has exactly one side strictly positive and both non-negative (R1a).
113    InvalidLineAmount,
114    NonPostableAccount(String),
115    AccountNotFound(Uuid),
116    PartyRequired(String),
117    PartyNotAllowed(String),
118    PeriodClosed,
119    /// A block-enforcement budget position would be exceeded: the posting is
120    /// refused with the breaching positions attached (see `budget_control`).
121    BudgetExceeded(Vec<BudgetBreach>),
122    Conflict(String),
123    Internal(String),
124}
125
126impl PostingError {
127    pub fn code(&self) -> &'static str {
128        match self {
129            PostingError::TooFewLines => "too_few_lines",
130            PostingError::Unbalanced => "unbalanced",
131            PostingError::InvalidLineAmount => "invalid_line_amount",
132            PostingError::NonPostableAccount(_) => "non_postable_account",
133            PostingError::AccountNotFound(_) => "account_not_found",
134            PostingError::PartyRequired(_) => "party_required",
135            PostingError::PartyNotAllowed(_) => "party_not_allowed",
136            PostingError::PeriodClosed => "period_closed",
137            PostingError::BudgetExceeded(_) => "budget_exceeded",
138            PostingError::Conflict(_) => "conflict",
139            PostingError::Internal(_) => "internal_error",
140        }
141    }
142
143    /// HTTP status: validation → 422, missing account → 404, internal → 500.
144    pub fn http_status(&self) -> u16 {
145        match self {
146            PostingError::AccountNotFound(_) => 404,
147            PostingError::Internal(_) => 500,
148            _ => 422,
149        }
150    }
151}
152
153impl std::fmt::Display for PostingError {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        write!(f, "{}", self.code())
156    }
157}
158impl std::error::Error for PostingError {}
159
160/// Sink for GL-posting domain events (the event-bus seam). Fire-and-forget. A real adapter
161/// (e.g. backbone-messaging) implements this; tests use a recording sink; the default logs.
162pub trait PostingEventSink: Send + Sync {
163    fn publish(&self, event: PostingEvent);
164}
165
166/// Published when the GL-posting service records a balanced entry.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct AccountingPostPosted {
169    pub post_id: Uuid,
170    pub journal_id: Uuid,
171    /// The legacy tenancy twin (ADR-0029) — carried in the payload for unstripped consumers.
172    pub company_id: Uuid,
173    pub source_type: String,
174    pub source_id: Uuid,
175    pub total_debit: Decimal,
176    pub total_credit: Decimal,
177    pub occurred_at: DateTime<Utc>,
178}
179
180/// Published when a posting is rejected (validation failure). Carries the stable error code.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct AccountingPostFailed {
183    /// The legacy tenancy twin (ADR-0029) — see `AccountingPostPosted::company_id`.
184    pub company_id: Uuid,
185    pub source_type: String,
186    pub source_id: Uuid,
187    pub error_code: String,
188    pub error_message: String,
189    pub occurred_at: DateTime<Utc>,
190}
191
192/// GL-posting domain events (discriminated union) for the module event bus.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(tag = "type")]
195pub enum PostingEvent {
196    AccountingPostPosted(AccountingPostPosted),
197    AccountingPostFailed(AccountingPostFailed),
198}
199
200/// Map a posting source_type + posting_type to (journal_type, journal_source) enum labels.
201pub fn map_source(source_type: &str, posting_type: &str) -> (&'static str, &'static str) {
202    if posting_type == "reversal" {
203        return ("reversing", "adjustment");
204    }
205    match source_type {
206        "order" => ("sales", "order"),
207        "payment" => ("cash_receipt", "payment"),
208        "settlement" => ("general", "settlement"),
209        "refund" => ("general", "adjustment"),
210        "expense" => ("purchase", "adjustment"),
211        "inventory" => ("general", "system"),
212        // Reconciliation-generated moves (exchange difference now, cash-basis tax later).
213        "reconciliation" => ("general", "system"),
214        // Gateway settlement's fee companion post — provider-notified, never keyed by hand.
215        "gateway_fee" => ("general", "system"),
216        _ => ("general", "manual"),
217    }
218}