Skip to main content

backbone_gl_posting/
lib.rs

1//! # backbone-gl-posting
2//!
3//! The GL-posting **wire contract** shared across Backbone producers — the serialized envelope a
4//! producer (payment, payment-gateway, selling, inventory, billing, …) emits, and the sink port the
5//! composition layer implements over accounting's `PostingService`. This crate owns the contract so
6//! each producer stops carrying its own structurally-identical copy (ADR-001 §6's deferred cleanup).
7//!
8//! - A producer builds an [`AccountingPostEnvelope`] (balanced `Dr … · Cr …` lines) and reaches
9//!   accounting ONLY through a [`GlPostSink`] — zero normal Cargo edge into backbone-accounting.
10//! - A composition ACL implements `GlPostSink` and maps the envelope into accounting's `PostingRequest`
11//!   (injecting the accounting-owned `cost_center`/`project`/`department` the envelope deliberately omits).
12//! - Idempotency: accounting dedups on `source_id` (the producer document id, opaque to accounting).
13//!
14//! This crate is framework plumbing: it depends on `serde` + primitives only, never on a domain module
15//! or accounting itself.
16//!
17//! The companion seam is the **reconciliation contract** ([`ReconcileSink`]): producers settle and
18//! clear through reconciliation edges on accounting's journal lines via the same zero-edge posture —
19//! a locator-based, connection-taking port implemented by the composition layer.
20
21mod reconcile;
22
23pub use reconcile::*;
24
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use uuid::Uuid;
28
29/// One debit/credit line of a GL post.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct GlPostLine {
32    pub account_id: Uuid,
33    pub debit: Decimal,
34    pub credit: Decimal,
35    pub party_type: Option<String>,
36    pub party_id: Option<Uuid>,
37    pub description: Option<String>,
38}
39
40impl GlPostLine {
41    pub fn debit(account_id: Uuid, amount: Decimal) -> Self {
42        Self { account_id, debit: amount, credit: Decimal::ZERO, party_type: None, party_id: None, description: None }
43    }
44    pub fn credit(account_id: Uuid, amount: Decimal) -> Self {
45        Self { account_id, debit: Decimal::ZERO, credit: amount, party_type: None, party_id: None, description: None }
46    }
47    pub fn with_party(mut self, party_type: &str, party_id: Uuid) -> Self {
48        self.party_type = Some(party_type.to_string());
49        self.party_id = Some(party_id);
50        self
51    }
52    pub fn with_description(mut self, d: impl Into<String>) -> Self {
53        self.description = Some(d.into());
54        self
55    }
56}
57
58/// The serialized GL-post envelope a producer emits. `source_type` discriminates the producer
59/// ("payment", "gateway_fee", "sales", …); `source_id` is the producer document id accounting dedups on.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct AccountingPostEnvelope {
62    pub idempotency_key: String,
63    pub company_id: Uuid,
64    pub branch_id: Option<Uuid>,
65    /// Posting source discriminator — e.g. "payment" (settlement), "gateway_fee".
66    pub source_type: String,
67    /// The producer document id — opaque to accounting, used as the dedup key.
68    pub source_id: Uuid,
69    pub source_reference: Option<String>,
70    pub posting_date: chrono::NaiveDate,
71    pub currency: String,
72    /// "original" | "reversal".
73    pub posting_type: String,
74    /// Set only when `posting_type == "reversal"`.
75    pub reverses_post_id: Option<Uuid>,
76    pub description: Option<String>,
77    pub lines: Vec<GlPostLine>,
78}
79
80impl AccountingPostEnvelope {
81    pub fn totals(&self) -> (Decimal, Decimal) {
82        (self.lines.iter().map(|l| l.debit).sum(), self.lines.iter().map(|l| l.credit).sum())
83    }
84    pub fn is_balanced(&self) -> bool {
85        let (d, c) = self.totals();
86        d == c && !self.lines.is_empty()
87    }
88}
89
90/// Accounting's ack: the resulting post + journal, and whether the idempotency key short-circuited.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92pub struct GlPostAck {
93    pub post_id: Uuid,
94    pub journal_id: Uuid,
95    pub idempotent_reuse: bool,
96}
97
98/// Why accounting rejected a post (e.g. a closed period). The producer leaves the document retryable.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct GlPostRejected {
101    pub code: String,
102    pub message: String,
103}
104
105/// The GL-posting seam. A composing service implements this over accounting's `PostingService`;
106/// tests record. Producers never depend on backbone-accounting — they reach it only through this port.
107#[async_trait::async_trait]
108pub trait GlPostSink: Send + Sync {
109    async fn post(&self, envelope: &AccountingPostEnvelope) -> Result<GlPostAck, GlPostRejected>;
110}