Skip to main content

commerce_theory/
accounting.rs

1use crate::foundation::*;
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
5pub enum PostingSide {
6    Debit,
7    Credit,
8}
9
10domain_struct! {
11    pub struct LedgerAccount {
12        id: Id,
13        name: String,
14    }
15}
16
17domain_struct! {
18    pub struct Posting {
19        account: LedgerAccount,
20        side: PostingSide,
21        amount: Money,
22    }
23}
24
25#[must_use]
26pub const fn debit(account: LedgerAccount, amount: Money) -> Posting {
27    Posting::new(account, PostingSide::Debit, amount)
28}
29
30#[must_use]
31pub const fn credit(account: LedgerAccount, amount: Money) -> Posting {
32    Posting::new(account, PostingSide::Credit, amount)
33}
34
35pub fn debit_total(postings: &[Posting]) -> DomainResult<Money> {
36    checked_sum(
37        postings.iter().map(|posting| {
38            if posting.side == PostingSide::Debit {
39                posting.amount
40            } else {
41                0
42            }
43        }),
44        "debit_total",
45    )
46}
47
48pub fn credit_total(postings: &[Posting]) -> DomainResult<Money> {
49    checked_sum(
50        postings.iter().map(|posting| {
51            if posting.side == PostingSide::Credit {
52                posting.amount
53            } else {
54                0
55            }
56        }),
57        "credit_total",
58    )
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub struct BalancedJournalEntry {
64    pub(crate) postings: Vec<Posting>,
65}
66
67impl BalancedJournalEntry {
68    pub fn try_new(postings: Vec<Posting>) -> DomainResult<Self> {
69        if debit_total(&postings)? != credit_total(&postings)? {
70            return Err(ValidationError::Invariant("journal entry is not balanced"));
71        }
72        Ok(Self { postings })
73    }
74
75    #[must_use]
76    pub fn postings(&self) -> &[Posting] {
77        &self.postings
78    }
79}
80
81domain_struct! {
82    pub struct AccountingAccounts {
83        cash: LedgerAccount,
84        deferred_revenue: LedgerAccount,
85        revenue: LedgerAccount,
86        refunds: LedgerAccount,
87        inventory: LedgerAccount,
88        cogs: LedgerAccount,
89    }
90}
91
92pub fn payment_captured_journal(
93    accounts: &AccountingAccounts,
94    amount: Money,
95) -> DomainResult<BalancedJournalEntry> {
96    BalancedJournalEntry::try_new(vec![
97        debit(accounts.cash.clone(), amount),
98        credit(accounts.deferred_revenue.clone(), amount),
99    ])
100}
101
102pub fn refund_issued_journal(
103    accounts: &AccountingAccounts,
104    amount: Money,
105) -> DomainResult<BalancedJournalEntry> {
106    BalancedJournalEntry::try_new(vec![
107        debit(accounts.refunds.clone(), amount),
108        credit(accounts.cash.clone(), amount),
109    ])
110}
111
112domain_struct! {
113    pub struct AdvancedAccountingAccounts {
114        operating: AccountingAccounts,
115        accounts_receivable: LedgerAccount,
116        accounts_payable: LedgerAccount,
117        tax_liability: LedgerAccount,
118        marketplace_clearing: LedgerAccount,
119        marketplace_fees: LedgerAccount,
120        chargeback_reserve: LedgerAccount,
121        chargeback_expense: LedgerAccount,
122        realized_fx_gain: LedgerAccount,
123        realized_fx_loss: LedgerAccount,
124        unrealized_fx_gain: LedgerAccount,
125        unrealized_fx_loss: LedgerAccount,
126    }
127}
128
129pub fn invoice_accrual_journal(
130    accounts: &AdvancedAccountingAccounts,
131    subtotal: Money,
132    tax: Money,
133    total: Money,
134) -> DomainResult<BalancedJournalEntry> {
135    if total != checked_add(subtotal, tax, "invoice accrual total")? {
136        return Err(ValidationError::AccountingInvariantFailed);
137    }
138    BalancedJournalEntry::try_new(vec![
139        debit(accounts.accounts_receivable.clone(), total),
140        credit(accounts.operating.revenue.clone(), subtotal),
141        credit(accounts.tax_liability.clone(), tax),
142    ])
143}
144
145pub fn cash_sale_journal(
146    accounts: &AdvancedAccountingAccounts,
147    subtotal: Money,
148    tax: Money,
149    total: Money,
150) -> DomainResult<BalancedJournalEntry> {
151    if total != checked_add(subtotal, tax, "cash sale total")? {
152        return Err(ValidationError::AccountingInvariantFailed);
153    }
154    BalancedJournalEntry::try_new(vec![
155        debit(accounts.operating.cash.clone(), total),
156        credit(accounts.operating.revenue.clone(), subtotal),
157        credit(accounts.tax_liability.clone(), tax),
158    ])
159}
160
161pub fn receivable_collection_journal(
162    accounts: &AdvancedAccountingAccounts,
163    amount: Money,
164) -> DomainResult<BalancedJournalEntry> {
165    BalancedJournalEntry::try_new(vec![
166        debit(accounts.operating.cash.clone(), amount),
167        credit(accounts.accounts_receivable.clone(), amount),
168    ])
169}
170
171pub fn supplier_bill_journal(
172    accounts: &AdvancedAccountingAccounts,
173    amount: Money,
174) -> DomainResult<BalancedJournalEntry> {
175    BalancedJournalEntry::try_new(vec![
176        debit(accounts.operating.inventory.clone(), amount),
177        credit(accounts.accounts_payable.clone(), amount),
178    ])
179}
180
181pub fn supplier_payment_journal(
182    accounts: &AdvancedAccountingAccounts,
183    amount: Money,
184) -> DomainResult<BalancedJournalEntry> {
185    BalancedJournalEntry::try_new(vec![
186        debit(accounts.accounts_payable.clone(), amount),
187        credit(accounts.operating.cash.clone(), amount),
188    ])
189}
190
191pub fn marketplace_sale_clearing_journal(
192    accounts: &AdvancedAccountingAccounts,
193    gross: Money,
194) -> DomainResult<BalancedJournalEntry> {
195    BalancedJournalEntry::try_new(vec![
196        debit(accounts.marketplace_clearing.clone(), gross),
197        credit(accounts.operating.revenue.clone(), gross),
198    ])
199}
200
201pub fn marketplace_settlement_journal(
202    accounts: &AdvancedAccountingAccounts,
203    gross: Money,
204    fee: Money,
205    payout: Money,
206) -> DomainResult<BalancedJournalEntry> {
207    if checked_add(payout, fee, "marketplace settlement")? != gross {
208        return Err(ValidationError::AccountingInvariantFailed);
209    }
210    BalancedJournalEntry::try_new(vec![
211        debit(accounts.operating.cash.clone(), payout),
212        debit(accounts.marketplace_fees.clone(), fee),
213        credit(accounts.marketplace_clearing.clone(), gross),
214    ])
215}
216
217pub fn marketplace_payout_reconciliation_journal(
218    accounts: &AdvancedAccountingAccounts,
219    gross: Money,
220    fee: Money,
221    refund: Money,
222    reserve: Money,
223    tax: Money,
224    payout: Money,
225) -> DomainResult<BalancedJournalEntry> {
226    let debits = checked_sum(
227        [payout, fee, refund, reserve, tax],
228        "marketplace reconciliation",
229    )?;
230    if debits != gross {
231        return Err(ValidationError::AccountingInvariantFailed);
232    }
233    BalancedJournalEntry::try_new(vec![
234        debit(accounts.operating.cash.clone(), payout),
235        debit(accounts.marketplace_fees.clone(), fee),
236        debit(accounts.operating.refunds.clone(), refund),
237        debit(accounts.chargeback_reserve.clone(), reserve),
238        debit(accounts.tax_liability.clone(), tax),
239        credit(accounts.marketplace_clearing.clone(), gross),
240    ])
241}
242
243pub fn chargeback_reserve_journal(
244    accounts: &AdvancedAccountingAccounts,
245    amount: Money,
246) -> DomainResult<BalancedJournalEntry> {
247    BalancedJournalEntry::try_new(vec![
248        debit(accounts.chargeback_expense.clone(), amount),
249        credit(accounts.chargeback_reserve.clone(), amount),
250    ])
251}
252
253pub fn chargeback_settlement_journal(
254    accounts: &AdvancedAccountingAccounts,
255    amount: Money,
256) -> DomainResult<BalancedJournalEntry> {
257    BalancedJournalEntry::try_new(vec![
258        debit(accounts.chargeback_reserve.clone(), amount),
259        credit(accounts.operating.cash.clone(), amount),
260    ])
261}
262
263pub fn unrealized_fx_gain_journal(
264    accounts: &AdvancedAccountingAccounts,
265    amount: Money,
266) -> DomainResult<BalancedJournalEntry> {
267    BalancedJournalEntry::try_new(vec![
268        debit(accounts.accounts_receivable.clone(), amount),
269        credit(accounts.unrealized_fx_gain.clone(), amount),
270    ])
271}
272
273pub fn unrealized_fx_loss_journal(
274    accounts: &AdvancedAccountingAccounts,
275    amount: Money,
276) -> DomainResult<BalancedJournalEntry> {
277    BalancedJournalEntry::try_new(vec![
278        debit(accounts.unrealized_fx_loss.clone(), amount),
279        credit(accounts.accounts_receivable.clone(), amount),
280    ])
281}
282
283pub fn realized_fx_gain_journal(
284    accounts: &AdvancedAccountingAccounts,
285    amount: Money,
286) -> DomainResult<BalancedJournalEntry> {
287    BalancedJournalEntry::try_new(vec![
288        debit(accounts.operating.cash.clone(), amount),
289        credit(accounts.realized_fx_gain.clone(), amount),
290    ])
291}
292
293pub fn realized_fx_loss_journal(
294    accounts: &AdvancedAccountingAccounts,
295    amount: Money,
296) -> DomainResult<BalancedJournalEntry> {
297    BalancedJournalEntry::try_new(vec![
298        debit(accounts.realized_fx_loss.clone(), amount),
299        credit(accounts.operating.cash.clone(), amount),
300    ])
301}