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}