Skip to main content

backbone_maintenance/application/service/
maintenance_gl.rs

1//! Outbound GL-posting port (hand-authored, user-owned) — maintenance's side of the GL-posting contract.
2//!
3//! Maintenance emits ONE balanced posting per completed visit, `source_type = "maintenance"`,
4//! `posting_type='original'`, `source_id = visit_id`:
5//!   Dr Maintenance Expense (total) · Cr Inventory Parts (parts value) · Cr Labor Payable (labor).
6//! Because `total = parts + labor`, it balances. The 9th GL producer. Reached only through a `GlPostSink`;
7//! the ACL adapter maps the envelope into accounting's `PostingRequest`. ZERO normal Cargo edge to
8//! accounting — the envelope is the wire contract, duplicated per producer by design.
9
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14/// One debit/credit line of an emitted posting. Exactly one of `debit`/`credit` is > 0.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct GlPostLine {
17    pub account_id: Uuid,
18    pub debit: Decimal,
19    pub credit: Decimal,
20    pub party_type: Option<String>,
21    pub party_id: Option<Uuid>,
22    pub description: Option<String>,
23}
24
25impl GlPostLine {
26    pub fn debit(account_id: Uuid, amount: Decimal) -> Self {
27        Self { account_id, debit: amount, credit: Decimal::ZERO, party_type: None, party_id: None, description: None }
28    }
29    pub fn credit(account_id: Uuid, amount: Decimal) -> Self {
30        Self { account_id, debit: Decimal::ZERO, credit: amount, party_type: None, party_id: None, description: None }
31    }
32    pub fn with_description(mut self, d: impl Into<String>) -> Self {
33        self.description = Some(d.into());
34        self
35    }
36}
37
38/// A balanced posting request emitted by this module's completion verb — the contract envelope.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct AccountingPostEnvelope {
41    pub idempotency_key: String,
42    /// Legacy twin (ADR-0029): accounting's `PostingRequest` wire shape still carries the tenant;
43    /// filled from the ambient org scope's company echo. Nothing in this module keys on it.
44    pub company_id: Uuid,
45    pub branch_id: Option<Uuid>,
46    /// Posting source discriminator — the emitting domain's stable key (this module posts
47    /// "maintenance").
48    pub source_type: String,
49    /// The producer voucher id — the record whose completion produced this posting (here: the
50    /// completed maintenance visit).
51    pub source_id: Uuid,
52    pub source_reference: Option<String>,
53    pub posting_date: chrono::NaiveDate,
54    pub currency: String,
55    /// "original" (this module does not reverse postings in the MVP).
56    pub posting_type: String,
57    pub description: Option<String>,
58    pub lines: Vec<GlPostLine>,
59}
60
61impl AccountingPostEnvelope {
62    pub fn totals(&self) -> (Decimal, Decimal) {
63        (
64            self.lines.iter().map(|l| l.debit).sum(),
65            self.lines.iter().map(|l| l.credit).sum(),
66        )
67    }
68    pub fn is_balanced(&self) -> bool {
69        let (d, c) = self.totals();
70        d == c && !self.lines.is_empty()
71    }
72}
73
74/// Acknowledgement returned by the GL after a successful post.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct GlPostAck {
77    pub post_id: Uuid,
78    pub journal_id: Uuid,
79    pub idempotent_reuse: bool,
80}
81
82/// Rejection returned by the GL (validation failure). `code` is the stable contract error string.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct GlPostRejected {
85    pub code: String,
86    pub message: String,
87}
88
89/// The GL-posting seam. A composing service implements this over accounting's `PostingService`.
90#[async_trait::async_trait]
91pub trait GlPostSink: Send + Sync {
92    async fn post(&self, envelope: &AccountingPostEnvelope) -> Result<GlPostAck, GlPostRejected>;
93}