Period close, bank rec, AR/AP, multi-currency (41 MCP tools)
This commit is contained in:
1 parent
eb30becb95
commit
6b6e88516d
1 file changed
+888
-16
@@ -12,8 +12,9 @@ use crate::journal::{JournalLine, JournalWriter, read_and_verify_journal};
|
|||||||
use crate::schema;
|
use crate::schema;
|
||||||
use chrono::{Months, NaiveDate};
|
use chrono::{Months, NaiveDate};
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetId, AssetStatus, Currency, EntryId, EntryStatus, Ledger,
|
AccountId, AccountType, AssetId, AssetStatus, BillId, Currency, CustomerId, DocumentLine,
|
||||||
LedgerError, Money, NewAsset, Side, reports,
|
EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money, NewAsset, PeriodId, Side,
|
||||||
|
VendorId, arap, reports,
|
||||||
};
|
};
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -899,6 +900,511 @@ impl Store {
|
|||||||
Ok(entry_id)
|
Ok(entry_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Accounting periods
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Create an accounting period. Journaled as `period.created`.
|
||||||
|
pub fn create_period(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
) -> Result<PeriodId, StoreError> {
|
||||||
|
// Pure pre-validation so the journal write cannot be followed by a
|
||||||
|
// failed mutation.
|
||||||
|
ledger_core::periods::validate_period_range(start, end)?;
|
||||||
|
let id = self.ledger.next_period_id();
|
||||||
|
let line = self.append_journal(
|
||||||
|
"period.created",
|
||||||
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"start": start.to_string(),
|
||||||
|
"end": end.to_string(),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
let live = self.ledger.create_period(name, start, end)?;
|
||||||
|
debug_assert_eq!(live, id, "id allocator diverged from journal");
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close an accounting period, auto-posting the closing entry (revenue
|
||||||
|
/// and expense accounts zeroed into Retained Earnings). Journaled as
|
||||||
|
/// `period.closed` with the closing entry's id and its full detail
|
||||||
|
/// (date, description, lines).
|
||||||
|
///
|
||||||
|
/// The closing entry is part of the close: it is NOT separately
|
||||||
|
/// journaled as entry.created/entry.posted. Its detail rides in the
|
||||||
|
/// period.closed payload so the SQLite projection can record it; the
|
||||||
|
/// detail only exists after the close, so unlike most mutations this
|
||||||
|
/// journals after mutating (the pre-close validation makes a failed
|
||||||
|
/// close impossible, so the journal cannot be left behind). Replay
|
||||||
|
/// re-runs the deterministic close logic and checks the closing entry
|
||||||
|
/// id against the journal.
|
||||||
|
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, StoreError> {
|
||||||
|
// Pure pre-validation: a doomed close journals nothing, not even
|
||||||
|
// the Retained Earnings account creation below.
|
||||||
|
self.ledger.preview_close_period(id)?;
|
||||||
|
// Retained Earnings must exist before the close so its creation (if
|
||||||
|
// needed) is journaled as account.added; the close's own ensure
|
||||||
|
// then no-ops and replay stays deterministic.
|
||||||
|
self.ensure_control_account("3999", "Retained Earnings", AccountType::Equity)?;
|
||||||
|
let closing_entry = self.ledger.close_period(id)?;
|
||||||
|
let entry_detail = match closing_entry {
|
||||||
|
Some(eid) => {
|
||||||
|
let e = self
|
||||||
|
.ledger
|
||||||
|
.get_entry(eid)
|
||||||
|
.expect("close just posted the closing entry");
|
||||||
|
Some(json!({
|
||||||
|
"id": eid,
|
||||||
|
"date": e.date().to_string(),
|
||||||
|
"description": e.description(),
|
||||||
|
"lines": e.lines().iter().map(journal_line_json).collect::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let line = self.append_journal(
|
||||||
|
"period.closed",
|
||||||
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"closing_entry": closing_entry,
|
||||||
|
"entry": entry_detail,
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(closing_entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Bank reconciliation
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Mark a posted entry's line cleared (or uncleared) for bank
|
||||||
|
/// reconciliation. Journaled as `line.cleared`.
|
||||||
|
pub fn mark_cleared(
|
||||||
|
&mut self,
|
||||||
|
entry_id: EntryId,
|
||||||
|
line_index: usize,
|
||||||
|
cleared: bool,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.ledger.mark_cleared(entry_id, line_index, cleared)?;
|
||||||
|
let line = self.append_journal(
|
||||||
|
"line.cleared",
|
||||||
|
json!({
|
||||||
|
"entry_id": entry_id,
|
||||||
|
"line_index": line_index as u64,
|
||||||
|
"cleared": cleared,
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Accounts receivable / payable
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fetch a control account by code, creating it through the journaled
|
||||||
|
/// account path when missing. The ledger's internal ensure_account
|
||||||
|
/// then no-ops, which keeps replay deterministic: every account
|
||||||
|
/// creation is an `account.added` event.
|
||||||
|
fn ensure_control_account(
|
||||||
|
&mut self,
|
||||||
|
code: &str,
|
||||||
|
name: &str,
|
||||||
|
account_type: AccountType,
|
||||||
|
) -> Result<AccountId, StoreError> {
|
||||||
|
if let Some(a) = self.ledger.chart().find_by_code(code) {
|
||||||
|
return Ok(a.id);
|
||||||
|
}
|
||||||
|
self.add_account(code, name, account_type, None, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Journal an auto-posted two-line entry (debit one account, credit
|
||||||
|
/// another for the same amount) through the standard entry.created /
|
||||||
|
/// entry.line_added / entry.posted flow. Returns the predicted entry id
|
||||||
|
/// and the journaled lines; the caller mutates the ledger afterwards
|
||||||
|
/// and applies the lines to SQLite.
|
||||||
|
fn journal_auto_entry(
|
||||||
|
&mut self,
|
||||||
|
date: NaiveDate,
|
||||||
|
description: &str,
|
||||||
|
debit_account: AccountId,
|
||||||
|
credit_account: AccountId,
|
||||||
|
amount: Decimal,
|
||||||
|
) -> Result<(EntryId, Vec<JournalLine>), StoreError> {
|
||||||
|
let entry_id = self.ledger.next_entry_id();
|
||||||
|
let base = self.base_currency;
|
||||||
|
let mut journaled = Vec::with_capacity(4);
|
||||||
|
journaled.push(self.append_journal(
|
||||||
|
"entry.created",
|
||||||
|
json!({
|
||||||
|
"id": entry_id,
|
||||||
|
"date": date.to_string(),
|
||||||
|
"description": description,
|
||||||
|
}),
|
||||||
|
)?);
|
||||||
|
for (account, side) in [(debit_account, Side::Debit), (credit_account, Side::Credit)] {
|
||||||
|
journaled.push(self.append_journal(
|
||||||
|
"entry.line_added",
|
||||||
|
json!({
|
||||||
|
"id": entry_id,
|
||||||
|
"account": account,
|
||||||
|
"side": match side {
|
||||||
|
Side::Debit => "debit",
|
||||||
|
Side::Credit => "credit",
|
||||||
|
},
|
||||||
|
"amount": amount.to_string(),
|
||||||
|
"currency": base.code(),
|
||||||
|
}),
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
journaled.push(self.append_journal(
|
||||||
|
"entry.posted",
|
||||||
|
json!({
|
||||||
|
"id": entry_id,
|
||||||
|
"date": date.to_string(),
|
||||||
|
"description": description,
|
||||||
|
"reversal_of": Option::<u64>::None,
|
||||||
|
"lines": [
|
||||||
|
plain_line_json(debit_account, Side::Debit, Money::new(amount, base)),
|
||||||
|
plain_line_json(credit_account, Side::Credit, Money::new(amount, base)),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)?);
|
||||||
|
Ok((entry_id, journaled))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a customer. Journaled as `customer.added`.
|
||||||
|
pub fn add_customer(&mut self, name: &str) -> Result<CustomerId, StoreError> {
|
||||||
|
// The engine's only failure mode is an empty name; replicate it so
|
||||||
|
// the journal write cannot be followed by a failed mutation.
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(LedgerError::EmptyAssetDescription.into());
|
||||||
|
}
|
||||||
|
let id = self.ledger.next_customer_id();
|
||||||
|
let line = self.append_journal("customer.added", json!({ "id": id, "name": name }))?;
|
||||||
|
let live = self.ledger.add_customer(name)?;
|
||||||
|
debug_assert_eq!(live, id, "id allocator diverged from journal");
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a vendor. Journaled as `vendor.added`.
|
||||||
|
pub fn add_vendor(&mut self, name: &str) -> Result<VendorId, StoreError> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(LedgerError::EmptyAssetDescription.into());
|
||||||
|
}
|
||||||
|
let id = self.ledger.next_vendor_id();
|
||||||
|
let line = self.append_journal("vendor.added", json!({ "id": id, "name": name }))?;
|
||||||
|
let live = self.ledger.add_vendor(name)?;
|
||||||
|
debug_assert_eq!(live, id, "id allocator diverged from journal");
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a sales invoice. The auto-posted entry (Dr A/R, Cr Revenue)
|
||||||
|
/// is journaled first through the standard entry flow, then the
|
||||||
|
/// invoice itself as `invoice.created`.
|
||||||
|
pub fn create_invoice(
|
||||||
|
&mut self,
|
||||||
|
customer_id: CustomerId,
|
||||||
|
date: NaiveDate,
|
||||||
|
due_date: NaiveDate,
|
||||||
|
lines: Vec<DocumentLine>,
|
||||||
|
) -> Result<InvoiceId, StoreError> {
|
||||||
|
// Pure pre-validation mirroring Ledger::create_invoice, so the
|
||||||
|
// journal writes below cannot be followed by a failed mutation.
|
||||||
|
// (The auto-posted entry balances by construction and its accounts
|
||||||
|
// are ensured below.)
|
||||||
|
if self.ledger.get_customer(customer_id).is_none() {
|
||||||
|
return Err(LedgerError::UnknownCustomer(customer_id).into());
|
||||||
|
}
|
||||||
|
arap::validate_lines(&lines)?;
|
||||||
|
arap::validate_due_date(date, due_date)?;
|
||||||
|
if let Some(pid) = self.ledger.closed_period_containing(date) {
|
||||||
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
|
||||||
|
}
|
||||||
|
let ar = self.ensure_control_account("1200", "Accounts Receivable", AccountType::Asset)?;
|
||||||
|
let revenue = self.ensure_control_account("4000", "Revenue", AccountType::Revenue)?;
|
||||||
|
let invoice_id = self.ledger.next_invoice_id();
|
||||||
|
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
||||||
|
let description = format!("Invoice {invoice_id}");
|
||||||
|
let (entry_id, mut journaled) =
|
||||||
|
self.journal_auto_entry(date, &description, ar, revenue, total)?;
|
||||||
|
let doc_lines: Vec<Value> = lines.iter().map(document_line_json).collect();
|
||||||
|
journaled.push(self.append_journal(
|
||||||
|
"invoice.created",
|
||||||
|
json!({
|
||||||
|
"id": invoice_id,
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"date": date.to_string(),
|
||||||
|
"due_date": due_date.to_string(),
|
||||||
|
"lines": doc_lines,
|
||||||
|
"entry_id": entry_id,
|
||||||
|
}),
|
||||||
|
)?);
|
||||||
|
let live = self
|
||||||
|
.ledger
|
||||||
|
.create_invoice(customer_id, date, due_date, lines)?;
|
||||||
|
debug_assert_eq!(live, invoice_id, "id allocator diverged from journal");
|
||||||
|
debug_assert_eq!(
|
||||||
|
self.ledger
|
||||||
|
.get_invoice(live)
|
||||||
|
.expect("invoice exists")
|
||||||
|
.entry_id,
|
||||||
|
entry_id,
|
||||||
|
"invoice entry diverged from journal"
|
||||||
|
);
|
||||||
|
for line in &journaled {
|
||||||
|
apply_line_to_sqlite(&self.conn, line)?;
|
||||||
|
}
|
||||||
|
Ok(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a vendor bill. The auto-posted entry (Dr General Expenses, Cr
|
||||||
|
/// A/P) is journaled first through the standard entry flow, then the
|
||||||
|
/// bill itself as `bill.created`.
|
||||||
|
pub fn create_bill(
|
||||||
|
&mut self,
|
||||||
|
vendor_id: VendorId,
|
||||||
|
date: NaiveDate,
|
||||||
|
due_date: NaiveDate,
|
||||||
|
lines: Vec<DocumentLine>,
|
||||||
|
) -> Result<BillId, StoreError> {
|
||||||
|
if self.ledger.get_vendor(vendor_id).is_none() {
|
||||||
|
return Err(LedgerError::UnknownVendor(vendor_id).into());
|
||||||
|
}
|
||||||
|
arap::validate_lines(&lines)?;
|
||||||
|
arap::validate_due_date(date, due_date)?;
|
||||||
|
if let Some(pid) = self.ledger.closed_period_containing(date) {
|
||||||
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
|
||||||
|
}
|
||||||
|
let expense =
|
||||||
|
self.ensure_control_account("6999", "General Expenses", AccountType::Expense)?;
|
||||||
|
let ap = self.ensure_control_account("2000", "Accounts Payable", AccountType::Liability)?;
|
||||||
|
let bill_id = self.ledger.next_bill_id();
|
||||||
|
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
||||||
|
let description = format!("Bill {bill_id}");
|
||||||
|
let (entry_id, mut journaled) =
|
||||||
|
self.journal_auto_entry(date, &description, expense, ap, total)?;
|
||||||
|
let doc_lines: Vec<Value> = lines.iter().map(document_line_json).collect();
|
||||||
|
journaled.push(self.append_journal(
|
||||||
|
"bill.created",
|
||||||
|
json!({
|
||||||
|
"id": bill_id,
|
||||||
|
"vendor_id": vendor_id,
|
||||||
|
"date": date.to_string(),
|
||||||
|
"due_date": due_date.to_string(),
|
||||||
|
"lines": doc_lines,
|
||||||
|
"entry_id": entry_id,
|
||||||
|
}),
|
||||||
|
)?);
|
||||||
|
let live = self.ledger.create_bill(vendor_id, date, due_date, lines)?;
|
||||||
|
debug_assert_eq!(live, bill_id, "id allocator diverged from journal");
|
||||||
|
debug_assert_eq!(
|
||||||
|
self.ledger.get_bill(live).expect("bill exists").entry_id,
|
||||||
|
entry_id,
|
||||||
|
"bill entry diverged from journal"
|
||||||
|
);
|
||||||
|
for line in &journaled {
|
||||||
|
apply_line_to_sqlite(&self.conn, line)?;
|
||||||
|
}
|
||||||
|
Ok(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a customer payment against an invoice. The payment entry (Dr
|
||||||
|
/// cash, Cr A/R) is journaled through the standard entry flow first,
|
||||||
|
/// then `invoice.payment_received`.
|
||||||
|
pub fn receive_payment(
|
||||||
|
&mut self,
|
||||||
|
invoice_id: InvoiceId,
|
||||||
|
date: NaiveDate,
|
||||||
|
amount: Decimal,
|
||||||
|
cash_account: AccountId,
|
||||||
|
) -> Result<EntryId, StoreError> {
|
||||||
|
// Pure pre-validation mirroring Ledger::receive_payment, plus the
|
||||||
|
// account checks post_entry would perform: a failed post after the
|
||||||
|
// journal writes would leave the journal ahead of the ledger.
|
||||||
|
if amount <= Decimal::ZERO {
|
||||||
|
return Err(LedgerError::InvalidPaymentAmount(amount).into());
|
||||||
|
}
|
||||||
|
let balance = self
|
||||||
|
.ledger
|
||||||
|
.get_invoice(invoice_id)
|
||||||
|
.ok_or(LedgerError::UnknownInvoice(invoice_id))?
|
||||||
|
.balance();
|
||||||
|
if amount > balance {
|
||||||
|
return Err(LedgerError::Overpayment(amount, balance).into());
|
||||||
|
}
|
||||||
|
if let Some(pid) = self.ledger.closed_period_containing(date) {
|
||||||
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
|
||||||
|
}
|
||||||
|
let cash = self
|
||||||
|
.ledger
|
||||||
|
.chart()
|
||||||
|
.get(cash_account)
|
||||||
|
.ok_or(LedgerError::UnknownAccount(cash_account))?;
|
||||||
|
if cash.placeholder {
|
||||||
|
return Err(LedgerError::PlaceholderAccount(cash.code.clone()).into());
|
||||||
|
}
|
||||||
|
let ar = self.ensure_control_account("1200", "Accounts Receivable", AccountType::Asset)?;
|
||||||
|
let description = format!("Payment on invoice {invoice_id}");
|
||||||
|
let (entry_id, mut journaled) =
|
||||||
|
self.journal_auto_entry(date, &description, cash_account, ar, amount)?;
|
||||||
|
journaled.push(self.append_journal(
|
||||||
|
"invoice.payment_received",
|
||||||
|
json!({
|
||||||
|
"invoice_id": invoice_id,
|
||||||
|
"date": date.to_string(),
|
||||||
|
"amount": amount.to_string(),
|
||||||
|
"cash_account": cash_account,
|
||||||
|
"entry_id": entry_id,
|
||||||
|
}),
|
||||||
|
)?);
|
||||||
|
let live = self
|
||||||
|
.ledger
|
||||||
|
.receive_payment(invoice_id, date, amount, cash_account)?;
|
||||||
|
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
|
||||||
|
for line in &journaled {
|
||||||
|
apply_line_to_sqlite(&self.conn, line)?;
|
||||||
|
}
|
||||||
|
Ok(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pay a vendor bill. The payment entry (Dr A/P, Cr cash) is journaled
|
||||||
|
/// through the standard entry flow first, then `bill.paid`.
|
||||||
|
pub fn pay_bill(
|
||||||
|
&mut self,
|
||||||
|
bill_id: BillId,
|
||||||
|
date: NaiveDate,
|
||||||
|
amount: Decimal,
|
||||||
|
cash_account: AccountId,
|
||||||
|
) -> Result<EntryId, StoreError> {
|
||||||
|
if amount <= Decimal::ZERO {
|
||||||
|
return Err(LedgerError::InvalidPaymentAmount(amount).into());
|
||||||
|
}
|
||||||
|
let balance = self
|
||||||
|
.ledger
|
||||||
|
.get_bill(bill_id)
|
||||||
|
.ok_or(LedgerError::UnknownBill(bill_id))?
|
||||||
|
.balance();
|
||||||
|
if amount > balance {
|
||||||
|
return Err(LedgerError::Overpayment(amount, balance).into());
|
||||||
|
}
|
||||||
|
if let Some(pid) = self.ledger.closed_period_containing(date) {
|
||||||
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
|
||||||
|
}
|
||||||
|
let cash = self
|
||||||
|
.ledger
|
||||||
|
.chart()
|
||||||
|
.get(cash_account)
|
||||||
|
.ok_or(LedgerError::UnknownAccount(cash_account))?;
|
||||||
|
if cash.placeholder {
|
||||||
|
return Err(LedgerError::PlaceholderAccount(cash.code.clone()).into());
|
||||||
|
}
|
||||||
|
let ap = self.ensure_control_account("2000", "Accounts Payable", AccountType::Liability)?;
|
||||||
|
let description = format!("Payment on bill {bill_id}");
|
||||||
|
let (entry_id, mut journaled) =
|
||||||
|
self.journal_auto_entry(date, &description, ap, cash_account, amount)?;
|
||||||
|
journaled.push(self.append_journal(
|
||||||
|
"bill.paid",
|
||||||
|
json!({
|
||||||
|
"bill_id": bill_id,
|
||||||
|
"date": date.to_string(),
|
||||||
|
"amount": amount.to_string(),
|
||||||
|
"cash_account": cash_account,
|
||||||
|
"entry_id": entry_id,
|
||||||
|
}),
|
||||||
|
)?);
|
||||||
|
let live = self.ledger.pay_bill(bill_id, date, amount, cash_account)?;
|
||||||
|
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
|
||||||
|
for line in &journaled {
|
||||||
|
apply_line_to_sqlite(&self.conn, line)?;
|
||||||
|
}
|
||||||
|
Ok(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Foreign exchange
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Record a historical FX rate (foreign -> base) for reference.
|
||||||
|
/// Journaled as `fx.rate_set`.
|
||||||
|
pub fn set_fx_rate(
|
||||||
|
&mut self,
|
||||||
|
currency: Currency,
|
||||||
|
date: NaiveDate,
|
||||||
|
rate: Decimal,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.ledger.set_fx_rate(currency, date, rate)?;
|
||||||
|
let line = self.append_journal(
|
||||||
|
"fx.rate_set",
|
||||||
|
json!({
|
||||||
|
"currency": currency.code(),
|
||||||
|
"date": date.to_string(),
|
||||||
|
"rate": rate.to_string(),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a foreign-currency line to a draft entry. The base-currency
|
||||||
|
/// amount is stored alongside the foreign original and the rate used.
|
||||||
|
/// Journaled as `entry.line_added` with the fx fields
|
||||||
|
/// (`foreign_currency`, `foreign_amount`, `fx_rate`).
|
||||||
|
pub fn add_line_fx(
|
||||||
|
&mut self,
|
||||||
|
entry_id: EntryId,
|
||||||
|
account: AccountId,
|
||||||
|
side: Side,
|
||||||
|
foreign_currency: Currency,
|
||||||
|
foreign_amount: Decimal,
|
||||||
|
fx_rate: Decimal,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.ledger.add_line_fx(
|
||||||
|
entry_id,
|
||||||
|
account,
|
||||||
|
side,
|
||||||
|
foreign_currency,
|
||||||
|
foreign_amount,
|
||||||
|
fx_rate,
|
||||||
|
)?;
|
||||||
|
// Read back the stored base amount rather than recomputing it: the
|
||||||
|
// journal must carry exactly what the ledger holds.
|
||||||
|
let stored = self
|
||||||
|
.ledger
|
||||||
|
.get_entry(entry_id)
|
||||||
|
.expect("entry exists after add_line_fx")
|
||||||
|
.lines()
|
||||||
|
.last()
|
||||||
|
.expect("line was just added")
|
||||||
|
.amount;
|
||||||
|
let line = self.append_journal(
|
||||||
|
"entry.line_added",
|
||||||
|
json!({
|
||||||
|
"id": entry_id,
|
||||||
|
"account": account,
|
||||||
|
"side": match side {
|
||||||
|
Side::Debit => "debit",
|
||||||
|
Side::Credit => "credit",
|
||||||
|
},
|
||||||
|
"amount": stored.amount.to_string(),
|
||||||
|
"currency": stored.currency.code(),
|
||||||
|
"foreign_currency": foreign_currency.code(),
|
||||||
|
"foreign_amount": foreign_amount.to_string(),
|
||||||
|
"fx_rate": fx_rate.to_string(),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Blobs and attachments
|
// Blobs and attachments
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -1390,14 +1896,69 @@ fn journal_line_json(l: &ledger_core::JournalLine) -> Value {
|
|||||||
Side::Debit => "debit",
|
Side::Debit => "debit",
|
||||||
Side::Credit => "credit",
|
Side::Credit => "credit",
|
||||||
};
|
};
|
||||||
json!({
|
let mut v = json!({
|
||||||
"account": l.account,
|
"account": l.account,
|
||||||
"side": side,
|
"side": side,
|
||||||
"amount": l.amount.amount.to_string(),
|
"amount": l.amount.amount.to_string(),
|
||||||
"currency": l.amount.currency.code(),
|
"currency": l.amount.currency.code(),
|
||||||
|
});
|
||||||
|
// Foreign-currency provenance, when the line carries it. Journals
|
||||||
|
// written before foreign-currency lines existed have no such lines;
|
||||||
|
// readers treat missing fields as None.
|
||||||
|
let obj = v
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("journal_line_json builds an object");
|
||||||
|
if let Some(fc) = l.foreign_currency {
|
||||||
|
obj.insert(
|
||||||
|
"foreign_currency".to_string(),
|
||||||
|
Value::String(fc.code().to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(fa) = l.foreign_amount {
|
||||||
|
obj.insert("foreign_amount".to_string(), Value::String(fa.to_string()));
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plain base-currency line, serialized through the one line-JSON path.
|
||||||
|
fn plain_line_json(account: AccountId, side: Side, amount: Money) -> Value {
|
||||||
|
journal_line_json(&ledger_core::JournalLine {
|
||||||
|
account,
|
||||||
|
side,
|
||||||
|
amount,
|
||||||
|
foreign_currency: None,
|
||||||
|
foreign_amount: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One invoice/bill document line as journal JSON. Amounts are decimal
|
||||||
|
/// strings, never floats.
|
||||||
|
fn document_line_json(l: &DocumentLine) -> Value {
|
||||||
|
json!({
|
||||||
|
"description": l.description,
|
||||||
|
"amount": l.amount.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the `lines` array of an invoice/bill journal payload back into
|
||||||
|
/// document lines.
|
||||||
|
fn parse_document_lines(p: &Value) -> Result<Vec<DocumentLine>, StoreError> {
|
||||||
|
let arr = p
|
||||||
|
.get("lines")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or_else(|| StoreError::JournalCorrupt("missing 'lines'".into()))?;
|
||||||
|
arr.iter()
|
||||||
|
.map(|l| {
|
||||||
|
Ok(DocumentLine {
|
||||||
|
description: get_str(l, "description")?.to_string(),
|
||||||
|
amount: get_str(l, "amount")?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad line amount".into()))?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn flip_line_json(l: &ledger_core::JournalLine) -> Value {
|
fn flip_line_json(l: &ledger_core::JournalLine) -> Value {
|
||||||
let side = match l.side {
|
let side = match l.side {
|
||||||
Side::Debit => "credit",
|
Side::Debit => "credit",
|
||||||
@@ -1524,18 +2085,40 @@ fn replay_line_into_ledger(
|
|||||||
}
|
}
|
||||||
"entry.line_added" => {
|
"entry.line_added" => {
|
||||||
let id = get_u64(p, "id")?;
|
let id = get_u64(p, "id")?;
|
||||||
ledger.add_line(
|
let account = get_u64(p, "account")?;
|
||||||
id,
|
let side = parse_side(get_str(p, "side")?)?;
|
||||||
get_u64(p, "account")?,
|
let amount: Decimal = get_str(p, "amount")?
|
||||||
parse_side(get_str(p, "side")?)?,
|
|
||||||
Money::new(
|
|
||||||
get_str(p, "amount")?
|
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?,
|
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
|
||||||
Currency::new(get_str(p, "currency")?)?,
|
let currency = Currency::new(get_str(p, "currency")?)
|
||||||
),
|
.map_err(|_| StoreError::JournalCorrupt("bad currency".into()))?;
|
||||||
|
// The fx fields are optional: journals written before
|
||||||
|
// foreign-currency lines existed carry none, and replay as
|
||||||
|
// plain base-currency lines.
|
||||||
|
match p.get("foreign_currency").and_then(|v| v.as_str()) {
|
||||||
|
Some(fc) => {
|
||||||
|
let foreign_amount: Decimal = get_str(p, "foreign_amount")?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad foreign amount".into()))?;
|
||||||
|
let fx_rate: Decimal = get_str(p, "fx_rate")?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad fx rate".into()))?;
|
||||||
|
ledger.add_line_fx(
|
||||||
|
id,
|
||||||
|
account,
|
||||||
|
side,
|
||||||
|
Currency::new(fc).map_err(|_| {
|
||||||
|
StoreError::JournalCorrupt("bad foreign currency".into())
|
||||||
|
})?,
|
||||||
|
foreign_amount,
|
||||||
|
fx_rate,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
None => {
|
||||||
|
ledger.add_line(id, account, side, Money::new(amount, currency))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"entry.posted" => {
|
"entry.posted" => {
|
||||||
let id = get_u64(p, "id")?;
|
let id = get_u64(p, "id")?;
|
||||||
// New journals build the draft via entry.line_added events; old
|
// New journals build the draft via entry.line_added events; old
|
||||||
@@ -1611,6 +2194,110 @@ fn replay_line_into_ledger(
|
|||||||
parse_money(p, "proceeds")?,
|
parse_money(p, "proceeds")?,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
"period.created" => {
|
||||||
|
let want = get_u64(p, "id")?;
|
||||||
|
let got = ledger.create_period(
|
||||||
|
get_str(p, "name")?,
|
||||||
|
parse_date(get_str(p, "start")?)?,
|
||||||
|
parse_date(get_str(p, "end")?)?,
|
||||||
|
)?;
|
||||||
|
if got != want {
|
||||||
|
return Err(StoreError::ReplayMismatch(format!(
|
||||||
|
"period id: journal says {want}, replay got {got}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"period.closed" => {
|
||||||
|
let id = get_u64(p, "id")?;
|
||||||
|
let want = p.get("closing_entry").and_then(|v| v.as_u64());
|
||||||
|
// Deterministic: the close re-derives the closing entry from
|
||||||
|
// the replayed state, so it must match the journaled id. The
|
||||||
|
// journaled entry detail is for the projection; the ledger
|
||||||
|
// re-derives the entry itself.
|
||||||
|
let got = ledger.close_period(id)?;
|
||||||
|
if got != want {
|
||||||
|
return Err(StoreError::ReplayMismatch(format!(
|
||||||
|
"closing entry: journal says {want:?}, replay got {got:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"line.cleared" => {
|
||||||
|
ledger.mark_cleared(
|
||||||
|
get_u64(p, "entry_id")?,
|
||||||
|
get_u64(p, "line_index")? as usize,
|
||||||
|
p.get("cleared").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"customer.added" => {
|
||||||
|
let want = get_u64(p, "id")?;
|
||||||
|
let got = ledger.add_customer(get_str(p, "name")?)?;
|
||||||
|
if got != want {
|
||||||
|
return Err(StoreError::ReplayMismatch(format!(
|
||||||
|
"customer id: journal says {want}, replay got {got}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"vendor.added" => {
|
||||||
|
let want = get_u64(p, "id")?;
|
||||||
|
let got = ledger.add_vendor(get_str(p, "name")?)?;
|
||||||
|
if got != want {
|
||||||
|
return Err(StoreError::ReplayMismatch(format!(
|
||||||
|
"vendor id: journal says {want}, replay got {got}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"invoice.created" => {
|
||||||
|
// The entry was already rebuilt by the standard entry handlers;
|
||||||
|
// this only restores the invoice record, without re-posting.
|
||||||
|
ledger.replay_invoice(
|
||||||
|
get_u64(p, "id")?,
|
||||||
|
get_u64(p, "customer_id")?,
|
||||||
|
parse_date(get_str(p, "date")?)?,
|
||||||
|
parse_date(get_str(p, "due_date")?)?,
|
||||||
|
parse_document_lines(p)?,
|
||||||
|
get_u64(p, "entry_id")?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"bill.created" => {
|
||||||
|
ledger.replay_bill(
|
||||||
|
get_u64(p, "id")?,
|
||||||
|
get_u64(p, "vendor_id")?,
|
||||||
|
parse_date(get_str(p, "date")?)?,
|
||||||
|
parse_date(get_str(p, "due_date")?)?,
|
||||||
|
parse_document_lines(p)?,
|
||||||
|
get_u64(p, "entry_id")?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"invoice.payment_received" => {
|
||||||
|
// The payment entry was already rebuilt; this only moves the
|
||||||
|
// invoice's paid amount and status.
|
||||||
|
ledger.replay_invoice_payment(
|
||||||
|
get_u64(p, "invoice_id")?,
|
||||||
|
get_str(p, "amount")?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?,
|
||||||
|
get_u64(p, "entry_id")?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"bill.paid" => {
|
||||||
|
ledger.replay_bill_payment(
|
||||||
|
get_u64(p, "bill_id")?,
|
||||||
|
get_str(p, "amount")?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?,
|
||||||
|
get_u64(p, "entry_id")?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"fx.rate_set" => {
|
||||||
|
ledger.set_fx_rate(
|
||||||
|
Currency::new(get_str(p, "currency")?)
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad currency".into()))?,
|
||||||
|
parse_date(get_str(p, "date")?)?,
|
||||||
|
get_str(p, "rate")?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad rate".into()))?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
return Err(StoreError::JournalCorrupt(format!(
|
return Err(StoreError::JournalCorrupt(format!(
|
||||||
"unknown line type: {other}"
|
"unknown line type: {other}"
|
||||||
@@ -1620,9 +2307,52 @@ fn replay_line_into_ledger(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a payment to an invoice or bill row in the SQLite projection:
|
||||||
|
/// bump `amount_paid` and re-derive the status. Amounts are TEXT in
|
||||||
|
/// SQLite, so the math happens here in Rust with exact Decimals.
|
||||||
|
/// `table` is `invoices` or `bills`; an internal constant, never user input.
|
||||||
|
fn apply_document_payment(
|
||||||
|
conn: &Connection,
|
||||||
|
table: &str,
|
||||||
|
id: u64,
|
||||||
|
amount: Decimal,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let (paid_str, lines_json): (String, String) = conn.query_row(
|
||||||
|
&format!("SELECT amount_paid, lines_json FROM {table} WHERE id = ?"),
|
||||||
|
params![id as i64],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
let paid: Decimal = paid_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt(format!("bad amount_paid {paid_str}")))?;
|
||||||
|
let new_paid = paid + amount;
|
||||||
|
let lines: Vec<Value> = serde_json::from_str(&lines_json)
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad lines_json".into()))?;
|
||||||
|
let mut total = Decimal::ZERO;
|
||||||
|
for l in &lines {
|
||||||
|
total += get_str(l, "amount")?
|
||||||
|
.parse::<Decimal>()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad line amount".into()))?;
|
||||||
|
}
|
||||||
|
let status = if new_paid.is_zero() {
|
||||||
|
"open"
|
||||||
|
} else if new_paid >= total {
|
||||||
|
"paid"
|
||||||
|
} else {
|
||||||
|
"partial"
|
||||||
|
};
|
||||||
|
conn.execute(
|
||||||
|
&format!("UPDATE {table} SET amount_paid = ?, status = ? WHERE id = ?"),
|
||||||
|
params![new_paid.to_string(), status, id as i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply one journal line to the SQLite projection: INSERTs plus the events
|
/// Apply one journal line to the SQLite projection: INSERTs plus the events
|
||||||
/// mirror, then advance `last_applied_seq`. The applier only ever INSERTs
|
/// mirror, then advance `last_applied_seq`. The applier is a pure function
|
||||||
/// (and one UPDATE for receipt linking).
|
/// of the journal line: INSERTs for new facts, targeted UPDATEs/DELETEs
|
||||||
|
/// for lifecycle changes (receipt linking, disposal, period close,
|
||||||
|
/// cleared flags, document payments, fx rate updates).
|
||||||
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
|
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
|
||||||
let p = &line.payload;
|
let p = &line.payload;
|
||||||
match line.line_type.as_str() {
|
match line.line_type.as_str() {
|
||||||
@@ -1661,13 +2391,15 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
|||||||
.ok_or_else(|| StoreError::JournalCorrupt("entry.posted without lines".into()))?
|
.ok_or_else(|| StoreError::JournalCorrupt("entry.posted without lines".into()))?
|
||||||
{
|
{
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency) VALUES (?,?,?,?,?)",
|
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount) VALUES (?,?,?,?,?,?,?)",
|
||||||
params![
|
params![
|
||||||
get_u64(p, "id")? as i64,
|
get_u64(p, "id")? as i64,
|
||||||
get_u64(l, "account")? as i64,
|
get_u64(l, "account")? as i64,
|
||||||
get_str(l, "side")?,
|
get_str(l, "side")?,
|
||||||
get_str(l, "amount")?,
|
get_str(l, "amount")?,
|
||||||
get_str(l, "currency")?,
|
get_str(l, "currency")?,
|
||||||
|
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||||
|
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
@@ -1688,13 +2420,15 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
|||||||
.ok_or_else(|| StoreError::JournalCorrupt("entry.reversed without lines".into()))?
|
.ok_or_else(|| StoreError::JournalCorrupt("entry.reversed without lines".into()))?
|
||||||
{
|
{
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency) VALUES (?,?,?,?,?)",
|
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount) VALUES (?,?,?,?,?,?,?)",
|
||||||
params![
|
params![
|
||||||
get_u64(p, "id")? as i64,
|
get_u64(p, "id")? as i64,
|
||||||
get_u64(l, "account")? as i64,
|
get_u64(l, "account")? as i64,
|
||||||
get_str(l, "side")?,
|
get_str(l, "side")?,
|
||||||
get_str(l, "amount")?,
|
get_str(l, "amount")?,
|
||||||
get_str(l, "currency")?,
|
get_str(l, "currency")?,
|
||||||
|
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||||
|
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
@@ -1775,6 +2509,144 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
|||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
"period.created" => {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO periods(id, name, start, end, status, closing_entry) VALUES (?,?,?,?,?,?)",
|
||||||
|
params![
|
||||||
|
get_u64(p, "id")? as i64,
|
||||||
|
get_str(p, "name")?,
|
||||||
|
get_str(p, "start")?,
|
||||||
|
get_str(p, "end")?,
|
||||||
|
"open",
|
||||||
|
Option::<i64>::None,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"period.closed" => {
|
||||||
|
// The closing entry rides in the payload (it is not separately
|
||||||
|
// journaled as entry.created/entry.posted): record it like any
|
||||||
|
// posted entry so the projection stays complete, then mark the
|
||||||
|
// period row closed.
|
||||||
|
if !p.get("entry").map(|v| v.is_null()).unwrap_or(true) {
|
||||||
|
let e = &p["entry"];
|
||||||
|
let eid = get_u64(e, "id")? as i64;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)",
|
||||||
|
params![eid, get_str(e, "date")?, get_str(e, "description")?],
|
||||||
|
)?;
|
||||||
|
for l in e.get("lines").and_then(|v| v.as_array()).ok_or_else(|| {
|
||||||
|
StoreError::JournalCorrupt("period.closed entry without lines".into())
|
||||||
|
})? {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
params![
|
||||||
|
eid,
|
||||||
|
get_u64(l, "account")? as i64,
|
||||||
|
get_str(l, "side")?,
|
||||||
|
get_str(l, "amount")?,
|
||||||
|
get_str(l, "currency")?,
|
||||||
|
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||||
|
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE periods SET status = 'closed', closing_entry = ? WHERE id = ?",
|
||||||
|
params![
|
||||||
|
p.get("closing_entry")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|v| v as i64),
|
||||||
|
get_u64(p, "id")? as i64,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"line.cleared" => {
|
||||||
|
let entry_id = get_u64(p, "entry_id")? as i64;
|
||||||
|
let line_index = get_u64(p, "line_index")? as i64;
|
||||||
|
if p.get("cleared").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO cleared_lines(entry_id, line_index) VALUES (?, ?)",
|
||||||
|
params![entry_id, line_index],
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM cleared_lines WHERE entry_id = ? AND line_index = ?",
|
||||||
|
params![entry_id, line_index],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"customer.added" => {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO customers(id, name) VALUES (?, ?)",
|
||||||
|
params![get_u64(p, "id")? as i64, get_str(p, "name")?],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"vendor.added" => {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO vendors(id, name) VALUES (?, ?)",
|
||||||
|
params![get_u64(p, "id")? as i64, get_str(p, "name")?],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"invoice.created" => {
|
||||||
|
let lines = p.get("lines").ok_or_else(|| {
|
||||||
|
StoreError::JournalCorrupt("invoice.created without lines".into())
|
||||||
|
})?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices(id, customer_id, date, due_date, lines_json, amount_paid, status, entry_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
params![
|
||||||
|
get_u64(p, "id")? as i64,
|
||||||
|
get_u64(p, "customer_id")? as i64,
|
||||||
|
get_str(p, "date")?,
|
||||||
|
get_str(p, "due_date")?,
|
||||||
|
lines.to_string(),
|
||||||
|
"0",
|
||||||
|
"open",
|
||||||
|
get_u64(p, "entry_id")? as i64,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"bill.created" => {
|
||||||
|
let lines = p
|
||||||
|
.get("lines")
|
||||||
|
.ok_or_else(|| StoreError::JournalCorrupt("bill.created without lines".into()))?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bills(id, vendor_id, date, due_date, lines_json, amount_paid, status, entry_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
params![
|
||||||
|
get_u64(p, "id")? as i64,
|
||||||
|
get_u64(p, "vendor_id")? as i64,
|
||||||
|
get_str(p, "date")?,
|
||||||
|
get_str(p, "due_date")?,
|
||||||
|
lines.to_string(),
|
||||||
|
"0",
|
||||||
|
"open",
|
||||||
|
get_u64(p, "entry_id")? as i64,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"invoice.payment_received" => {
|
||||||
|
let amount: Decimal = get_str(p, "amount")?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
|
||||||
|
apply_document_payment(conn, "invoices", get_u64(p, "invoice_id")?, amount)?;
|
||||||
|
}
|
||||||
|
"bill.paid" => {
|
||||||
|
let amount: Decimal = get_str(p, "amount")?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
|
||||||
|
apply_document_payment(conn, "bills", get_u64(p, "bill_id")?, amount)?;
|
||||||
|
}
|
||||||
|
"fx.rate_set" => {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO fx_rates(currency, date, rate) VALUES (?,?,?) \
|
||||||
|
ON CONFLICT(currency, date) DO UPDATE SET rate=excluded.rate",
|
||||||
|
params![
|
||||||
|
get_str(p, "currency")?,
|
||||||
|
get_str(p, "date")?,
|
||||||
|
get_str(p, "rate")?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
return Err(StoreError::JournalCorrupt(format!(
|
return Err(StoreError::JournalCorrupt(format!(
|
||||||
"unknown line type: {other}"
|
"unknown line type: {other}"
|
||||||
|
|||||||
Reference in new issue
Block a user