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}