wip: pause checkpoint — P6 feature complete, gate interrupted (not for merge)
This commit is contained in:
1 parent
cc4f104f06
commit
ce36640874
68 files changed
+4592
-374
No files matched your search
@@ -63,6 +63,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
DOCUMENT_LINE_PROJECTS,
|
||||
DOCUMENT_STANDING,
|
||||
DOCUMENTS_V2,
|
||||
ATTACHMENT_TEXT,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -198,6 +199,15 @@ pub const DOCUMENT_STANDING: &str = "document_standing";
|
||||
/// discount and the fee, or refuse the unknown values, silently.
|
||||
pub const DOCUMENTS_V2: &str = "documents_v2";
|
||||
|
||||
/// Feature: the evidence pipeline (plan P6): `attachment.text_extracted`
|
||||
/// (a file's text, journaled as an extraction record), `attachment.annotated`
|
||||
/// (the facts a human read off a file), `entry.settles` (which entries a
|
||||
/// payment pays back), and `attachment.withdrawn` carrying `duplicate_of`.
|
||||
/// An older version doesn't know those event types, or would withdraw a
|
||||
/// duplicate without saying what it duplicates; it stops at the
|
||||
/// declaration instead (rules.md B-27, B-44).
|
||||
pub const ATTACHMENT_TEXT: &str = "attachment_text";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -218,7 +228,9 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"asset.serial_set",
|
||||
"asset.tagged",
|
||||
"asset.tax_basis_set",
|
||||
"attachment.annotated",
|
||||
"attachment.linked",
|
||||
"attachment.text_extracted",
|
||||
"attachment.unlinked",
|
||||
"attachment.withdrawn",
|
||||
"bill.created",
|
||||
@@ -239,6 +251,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"entry.party_set",
|
||||
"entry.posted",
|
||||
"entry.reversed",
|
||||
"entry.settles",
|
||||
"entry.vendor_set",
|
||||
"fx.rate_set",
|
||||
"invoice.created",
|
||||
@@ -858,6 +871,40 @@ mod tests {
|
||||
assert_eq!(up.adds(), vec!["the journal feature document_standing"]);
|
||||
}
|
||||
|
||||
/// The version before P6 (every feature through `documents_v2`) stops
|
||||
/// at a declaration of `attachment_text` as newer, never dropping a
|
||||
/// file's extraction, annotations or settlements; and books that
|
||||
/// declared every earlier feature still wait for it (B-27, D-39).
|
||||
#[test]
|
||||
fn attachment_text_is_its_own_feature() {
|
||||
assert!(KNOWN_FEATURES.contains(&ATTACHMENT_TEXT));
|
||||
let before: Vec<&str> = KNOWN_FEATURES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| *f != ATTACHMENT_TEXT)
|
||||
.collect();
|
||||
let l = line(
|
||||
FORMAT_SET,
|
||||
json!({"format": 2, "features": [DOCUMENTS_V2, ATTACHMENT_TEXT]}),
|
||||
);
|
||||
match declared_knowing(&l, &before) {
|
||||
Err(StoreError::NewerJournal { need, .. }) => {
|
||||
assert_eq!(need, "journal features attachment_text")
|
||||
}
|
||||
other => panic!("expected NewerJournal, got {other:?}"),
|
||||
}
|
||||
let older = JournalFormat {
|
||||
format: 2,
|
||||
features: before.iter().map(|f| f.to_string()).collect(),
|
||||
};
|
||||
assert!(matches!(
|
||||
older.require_feature(ATTACHMENT_TEXT),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
));
|
||||
let up = older.upgrade().expect("waits for it");
|
||||
assert_eq!(up.adds(), vec!["the journal feature attachment_text"]);
|
||||
}
|
||||
|
||||
/// The version before P4b S4.2 (every feature through
|
||||
/// `document_standing`) stops at a declaration of `documents_v2` as
|
||||
/// newer, never restoring a party's email or default terms as absent
|
||||
|
||||
@@ -47,7 +47,8 @@ pub use format::{FormatUpgrade, JournalFormat, SUPPORTED_JOURNAL_FORMAT};
|
||||
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||
pub use snapshot::{DiskCheck, Snapshot};
|
||||
pub use store::{
|
||||
Attachment, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE, LEGACY_DB_FILE,
|
||||
LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation, Statement, Store, Subject,
|
||||
VerifyReport, database_in_use, db_file, db_file_for, is_database_file,
|
||||
Attachment, DepreciationRun, DisposalPreview, EntryRow, EventRow, Extraction, ExtractionStatus,
|
||||
JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation,
|
||||
Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for,
|
||||
is_database_file,
|
||||
};
|
||||
@@ -17,7 +17,9 @@ use crate::error::StoreError;
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus};
|
||||
use ledger_core::{
|
||||
Annotation, AnnotationFields, ClassId, ProjectFacts, ProjectKind, ProjectStatus,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// The journal format these events need: an older reader stops at the
|
||||
@@ -361,6 +363,162 @@ pub fn entry_approved_of(p: &Value, seq: u64) -> Result<(u64, Approval), StoreEr
|
||||
))
|
||||
}
|
||||
|
||||
/// `attachment.text_extracted {attachment_id, text_sha256, extractor,
|
||||
/// version, pages, status}` (plan P6, feature `attachment_text`).
|
||||
pub fn text_extracted(
|
||||
attachment_id: u64,
|
||||
text_sha256: Option<&str>,
|
||||
extractor: &str,
|
||||
version: &str,
|
||||
pages: u32,
|
||||
status: &str,
|
||||
) -> Value {
|
||||
json!({
|
||||
"attachment_id": attachment_id,
|
||||
"text_sha256": text_sha256,
|
||||
"extractor": extractor,
|
||||
"version": version,
|
||||
"pages": pages,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an `attachment.text_extracted`: `(attachment_id, text_sha256,
|
||||
/// extractor, version, pages, status)`. A status this version doesn't know
|
||||
/// is newer (B-27: never "corrupt" for newer).
|
||||
pub fn text_extracted_of(line: &crate::journal::JournalLine) -> Result<TextExtracted, StoreError> {
|
||||
const E: &str = "attachment.text_extracted";
|
||||
let p = &line.payload;
|
||||
let status = str_of(p, E, "status")?;
|
||||
if !matches!(status.as_str(), "ok" | "none" | "failed") {
|
||||
return Err(crate::format::newer_content(
|
||||
line,
|
||||
format!("the extraction status {status:?}"),
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
u64_of(p, E, "attachment_id")?,
|
||||
opt_str(p, E, "text_sha256")?,
|
||||
str_of(p, E, "extractor")?,
|
||||
str_of(p, E, "version")?,
|
||||
u32::try_from(u64_of(p, E, "pages")?).map_err(|_| corrupt(E, "pages"))?,
|
||||
status,
|
||||
))
|
||||
}
|
||||
|
||||
/// What an `attachment.text_extracted` says: attachment id, text blob,
|
||||
/// extractor, version, pages, status.
|
||||
pub type TextExtracted = (u64, Option<String>, String, String, u32, String);
|
||||
|
||||
/// The annotation fields this version knows (the closed list, plan P6
|
||||
/// step 5).
|
||||
const ANNOTATION_FIELDS: &[&str] = &[
|
||||
"receipt_number",
|
||||
"payee",
|
||||
"amount",
|
||||
"date",
|
||||
"instrument",
|
||||
"category",
|
||||
"source",
|
||||
"reimbursable_to",
|
||||
];
|
||||
|
||||
/// `attachment.annotated {attachment_id, fields{...}, note?}`: only the
|
||||
/// fields given are written, so a canonical payload.
|
||||
pub fn annotated(a: &Annotation) -> Value {
|
||||
let mut fields = Map::new();
|
||||
put(&mut fields, "receipt_number", a.fields.receipt_number);
|
||||
put(&mut fields, "payee", a.fields.payee.clone());
|
||||
put(
|
||||
&mut fields,
|
||||
"amount",
|
||||
a.fields.amount.map(|v| v.to_string()),
|
||||
);
|
||||
put(&mut fields, "date", a.fields.date.map(|d| d.to_string()));
|
||||
put(&mut fields, "instrument", a.fields.instrument.clone());
|
||||
put(&mut fields, "category", a.fields.category.clone());
|
||||
put(&mut fields, "source", a.fields.source.clone());
|
||||
put(
|
||||
&mut fields,
|
||||
"reimbursable_to",
|
||||
a.fields.reimbursable_to.clone(),
|
||||
);
|
||||
let mut o = Map::new();
|
||||
o.insert("attachment_id".into(), json!(a.attachment_id));
|
||||
o.insert("fields".into(), Value::Object(fields));
|
||||
put(&mut o, "note", a.note.clone());
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read an `attachment.annotated`. A field this version doesn't know, or a
|
||||
/// mistyped one, is judged by its kind: unknown is newer (a later version
|
||||
/// wrote it, B-27), mistyped is corrupt.
|
||||
pub fn annotated_of(line: &crate::journal::JournalLine) -> Result<Annotation, StoreError> {
|
||||
const E: &str = "attachment.annotated";
|
||||
let p = &line.payload;
|
||||
let fields = p
|
||||
.get("fields")
|
||||
.filter(|v| v.is_object())
|
||||
.ok_or_else(|| corrupt(E, "fields"))?;
|
||||
for key in fields.as_object().expect("object").keys() {
|
||||
if !ANNOTATION_FIELDS.contains(&key.as_str()) {
|
||||
return Err(crate::format::newer_content(
|
||||
line,
|
||||
format!("the annotation field {key:?}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
let text = |k: &str| opt_str(fields, E, k);
|
||||
let receipt_number = match fields.get("receipt_number") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_u64()
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.ok_or_else(|| corrupt(E, "receipt_number"))?,
|
||||
),
|
||||
};
|
||||
let amount = match text("amount")? {
|
||||
None => None,
|
||||
Some(s) => Some(
|
||||
s.parse()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("{E}: amount")))?,
|
||||
),
|
||||
};
|
||||
let date = opt_date(fields, E, "date")?;
|
||||
Ok(Annotation {
|
||||
attachment_id: u64_of(p, E, "attachment_id")?,
|
||||
fields: AnnotationFields {
|
||||
receipt_number,
|
||||
payee: text("payee")?,
|
||||
amount,
|
||||
date,
|
||||
instrument: text("instrument")?,
|
||||
category: text("category")?,
|
||||
source: text("source")?,
|
||||
reimbursable_to: text("reimbursable_to")?,
|
||||
},
|
||||
note: opt_str(p, E, "note")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `entry.settles {entry_id, settles: [entry_id]}` (plan P6).
|
||||
pub fn entry_settles(entry: u64, settles: &[u64]) -> Value {
|
||||
json!({ "entry_id": entry, "settles": settles })
|
||||
}
|
||||
|
||||
/// Read an `entry.settles`: `(entry, settles)`.
|
||||
pub fn entry_settles_of(p: &Value) -> Result<(u64, Vec<u64>), StoreError> {
|
||||
const E: &str = "entry.settles";
|
||||
let settles = p
|
||||
.get("settles")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| corrupt(E, "settles"))?
|
||||
.iter()
|
||||
.map(|v| v.as_u64().ok_or_else(|| corrupt(E, "settles")))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok((u64_of(p, E, "entry_id")?, settles))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -444,6 +602,50 @@ mod tests {
|
||||
entry_approved_of(&entry_approved(5, &a), 9).unwrap(),
|
||||
(5, a)
|
||||
);
|
||||
let line = crate::journal::JournalLine {
|
||||
seq: 11,
|
||||
ts: "2026-10-27T00:00:00Z".into(),
|
||||
line_type: "attachment.annotated".into(),
|
||||
payload: annotated(&Annotation {
|
||||
attachment_id: 3,
|
||||
fields: AnnotationFields {
|
||||
receipt_number: Some(7),
|
||||
payee: Some("Harbor Design".into()),
|
||||
amount: Some("12.34".parse().unwrap()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1),
|
||||
instrument: Some("Check 1234".into()),
|
||||
category: None,
|
||||
source: Some("a folder".into()),
|
||||
reimbursable_to: Some("Avery Quill".into()),
|
||||
},
|
||||
note: Some("read on 2026-03-02".into()),
|
||||
}),
|
||||
prev: String::new(),
|
||||
};
|
||||
let read = annotated_of(&line).unwrap();
|
||||
assert_eq!(annotated(&read), line.payload);
|
||||
assert_eq!(
|
||||
entry_settles_of(&entry_settles(4, &[1, 2])).unwrap(),
|
||||
(4, vec![1, 2])
|
||||
);
|
||||
let x = crate::journal::JournalLine {
|
||||
seq: 12,
|
||||
ts: "2026-10-27T00:00:00Z".into(),
|
||||
line_type: "attachment.text_extracted".into(),
|
||||
payload: text_extracted(3, Some("00ff"), "wanderbooks", "1", 2, "ok"),
|
||||
prev: String::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
text_extracted_of(&x).unwrap(),
|
||||
(
|
||||
3,
|
||||
Some("00ff".into()),
|
||||
"wanderbooks".into(),
|
||||
"1".into(),
|
||||
2,
|
||||
"ok".into()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 1.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 15;
|
||||
pub const SCHEMA_VERSION: u32 = 16;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -396,5 +396,39 @@ ALTER TABLE invoices DROP COLUMN amount_paid;
|
||||
ALTER TABLE invoices DROP COLUMN status;
|
||||
ALTER TABLE bills DROP COLUMN amount_paid;
|
||||
ALTER TABLE bills DROP COLUMN status;
|
||||
"#,
|
||||
r#"
|
||||
-- v16: evidence (plan P6, feature attachment_text): what was read out of
|
||||
-- a file (`attachment.text_extracted`), the facts a human annotated it
|
||||
-- with (`attachment.annotated`), and the duplicate an attachment was
|
||||
-- withdrawn as. Extraction is its own record per (attachment, extractor,
|
||||
-- version): an extractor upgrade adds one, never replaces one. A text
|
||||
-- hash is the extraction's blob; the text itself is content-addressed in
|
||||
-- the blob store. Amounts stay TEXT (exact decimal), never REAL.
|
||||
CREATE TABLE IF NOT EXISTS extractions(
|
||||
attachment_id INTEGER NOT NULL,
|
||||
extractor TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
text_sha256 TEXT NULL,
|
||||
pages INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
PRIMARY KEY(attachment_id, extractor, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_extractions_text ON extractions(text_sha256);
|
||||
CREATE TABLE IF NOT EXISTS annotations(
|
||||
attachment_id INTEGER PRIMARY KEY,
|
||||
receipt_number INTEGER NULL,
|
||||
payee TEXT NULL,
|
||||
amount TEXT NULL,
|
||||
date TEXT NULL,
|
||||
instrument TEXT NULL,
|
||||
category TEXT NULL,
|
||||
source TEXT NULL,
|
||||
reimbursable_to TEXT NULL,
|
||||
note TEXT NULL,
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
ALTER TABLE attachments ADD COLUMN duplicate_of INTEGER NULL;
|
||||
"#,
|
||||
];
|
||||
@@ -19,10 +19,10 @@ use crate::error::StoreError;
|
||||
use crate::format::JournalFormat;
|
||||
use crate::journal::{GENESIS_PREV, JournalLine};
|
||||
use crate::store::{
|
||||
Attachment, BLOBS_DIR, JOURNAL_FILE, Reconciliation, Statement, Store, Subject,
|
||||
apply_line_to_sqlite, compare_projections, projection_rows, query_attachment_links,
|
||||
query_attachments, query_reconciliations, query_statements, read_attachment_bytes,
|
||||
replay_line_into_ledger,
|
||||
Attachment, BLOBS_DIR, Extraction, JOURNAL_FILE, Reconciliation, Statement, Store, Subject,
|
||||
apply_line_to_sqlite, compare_projections, projection_rows, query_annotations,
|
||||
query_attachment_links, query_attachments, query_extractions, query_reconciliations,
|
||||
query_statements, read_attachment_bytes, replay_line_into_ledger,
|
||||
};
|
||||
use ledger_core::AccountId;
|
||||
use ledger_core::{Currency, EntryId, Ledger};
|
||||
@@ -261,6 +261,16 @@ impl Snapshot {
|
||||
query_attachments(&self.conn, entry_id)
|
||||
}
|
||||
|
||||
/// Every extraction record, in seq order (as [`Store::extractions`]).
|
||||
pub fn extractions(&self) -> Result<Vec<Extraction>, StoreError> {
|
||||
query_extractions(&self.conn)
|
||||
}
|
||||
|
||||
/// Every annotation, by attachment id (as [`Store::annotations`]).
|
||||
pub fn annotations(&self) -> Result<Vec<ledger_core::Annotation>, StoreError> {
|
||||
query_annotations(&self.conn)
|
||||
}
|
||||
|
||||
/// Every attachment link beyond a receipt's filing entry, as
|
||||
/// (attachment id, subject), by attachment then subject: what
|
||||
/// [`Store::attachment_links`] reads, for every attachment at once.
|
||||
|
||||
@@ -45,6 +45,7 @@ use ledger_core::{
|
||||
payroll::{PayrollAccounts, payroll_entry_plan, payroll_remit_plan},
|
||||
reports,
|
||||
};
|
||||
use ledger_import::extract::{EXTRACTOR as E_EXTRACTOR, VERSION as E_VERSION};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
@@ -299,7 +300,11 @@ pub struct Attachment {
|
||||
pub blob_sha: String,
|
||||
/// Linked entry, if any.
|
||||
pub entry_id: Option<u64>,
|
||||
/// `receipts`, `formation`, `legal`, `tax`, `accounting`, `other`.
|
||||
/// `receipts`, or a document's: `formation`, `legal`, `tax`,
|
||||
/// `accounting`, `statement` (a bank or card statement), `contract`,
|
||||
/// `photo`, `warranty`, `import_source` (a bank export), `notices`,
|
||||
/// `correspondence`, `plans`, `history`, `other` (D-20: every company
|
||||
/// record, not only what the CPA needs).
|
||||
pub category: String,
|
||||
/// Original file name.
|
||||
pub file_name: String,
|
||||
@@ -315,6 +320,64 @@ pub struct Attachment {
|
||||
pub withdrawn_at: Option<String>,
|
||||
/// Why it was withdrawn, if it was.
|
||||
pub withdrawn_reason: Option<String>,
|
||||
/// The attachment it duplicates, when it was withdrawn as one
|
||||
/// (`attachment.withdrawn`'s `duplicate_of`, plan P6 step 4).
|
||||
pub duplicate_of: Option<u64>,
|
||||
}
|
||||
|
||||
/// What reading a file's text found (`attachment.text_extracted`, plan P6
|
||||
/// step 2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExtractionStatus {
|
||||
/// Text was read.
|
||||
Ok,
|
||||
/// There is no text to read (an image, an empty sheet): D-15's page
|
||||
/// pictures, not OCR.
|
||||
None,
|
||||
/// Reading was tried and failed: a human must read the file.
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ExtractionStatus {
|
||||
/// Its wire name, as journaled.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
ExtractionStatus::Ok => "ok",
|
||||
ExtractionStatus::None => "none",
|
||||
ExtractionStatus::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
/// A wire name back as a status.
|
||||
pub fn parse(key: &str) -> Option<Self> {
|
||||
match key {
|
||||
"ok" => Some(ExtractionStatus::Ok),
|
||||
"none" => Some(ExtractionStatus::None),
|
||||
"failed" => Some(ExtractionStatus::Failed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One record of reading a file's text, per (attachment, extractor,
|
||||
/// version): an extractor upgrade journals a new record, never replaces
|
||||
/// one.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Extraction {
|
||||
/// The attachment read.
|
||||
pub attachment_id: u64,
|
||||
/// The text's blob (its SHA-256), when there is text.
|
||||
pub text_sha256: Option<String>,
|
||||
/// What read it (`ledger_import::extract::EXTRACTOR`).
|
||||
pub extractor: String,
|
||||
/// That extractor's version.
|
||||
pub version: String,
|
||||
/// How many pages the file has (0 when it has none).
|
||||
pub pages: u32,
|
||||
/// What was found.
|
||||
pub status: ExtractionStatus,
|
||||
/// The journal seq of the record (the latest wins when reading).
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
/// Input to [`Store::run_payroll`]: one employee's gross pay and every
|
||||
@@ -3764,12 +3827,32 @@ impl Store {
|
||||
}
|
||||
|
||||
/// One filing of bytes into the blob store and journal.
|
||||
///
|
||||
/// Plan P6 step 4 (defect 4): bytes already filed (and not withdrawn)
|
||||
/// are one file, not a second record: the existing attachment is
|
||||
/// linked to the new subject and returned, `deduplicated` true. A
|
||||
/// filing with no subject (an unlinked receipt, a document) returns
|
||||
/// the existing record too, for the caller to link later. Legacy
|
||||
/// duplicates stay as they were written.
|
||||
fn attach(&mut self, req: AttachRequest<'_>) -> Result<(u64, String, bool), StoreError> {
|
||||
if let Some(eid) = req.entry_id {
|
||||
self.ledger
|
||||
.get_entry(eid)
|
||||
.ok_or(LedgerError::UnknownEntry(eid))?;
|
||||
}
|
||||
let sha = sha256_hex(req.bytes);
|
||||
if let Some(existing) = self.live_attachment_with_sha(&sha)? {
|
||||
if let Some(e) = req.entry_id {
|
||||
let covered = existing.entry_id == Some(e)
|
||||
|| self
|
||||
.attachment_links(existing.id)?
|
||||
.contains(&Subject::Entry(e));
|
||||
if !covered {
|
||||
self.link_attachment(existing.id, Subject::Entry(e))?;
|
||||
}
|
||||
}
|
||||
return Ok((existing.id, sha, true));
|
||||
}
|
||||
// Bytes hit the content-addressed store first; an orphan blob from a
|
||||
// failed journal append is harmless (unreferenced, deduplicated).
|
||||
let (sha, dedup) = self.blobs.put(req.bytes)?;
|
||||
@@ -3792,9 +3875,212 @@ impl Store {
|
||||
}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
// Plan P6 step 2: a file is read as it is filed. Books that haven't
|
||||
// declared `attachment_text` yet keep filing (the owner's go-ahead
|
||||
// gates the extraction records, not attaching a receipt); their
|
||||
// old and new files are read by `wanderbooks books extract` after
|
||||
// the upgrade.
|
||||
if self.format.has_feature(crate::format::ATTACHMENT_TEXT) {
|
||||
self.extract_and_journal(id, req.bytes, req.mime_type, req.file_name)?;
|
||||
}
|
||||
Ok((id, sha, dedup))
|
||||
}
|
||||
|
||||
/// The first attachment holding `sha`'s bytes that is not withdrawn.
|
||||
fn live_attachment_with_sha(&self, sha: &str) -> Result<Option<Attachment>, StoreError> {
|
||||
Ok(query_attachments(&self.conn, None)?
|
||||
.into_iter()
|
||||
.find(|a| a.blob_sha.eq_ignore_ascii_case(sha) && a.withdrawn_at.is_none()))
|
||||
}
|
||||
|
||||
/// Read a new attachment's text and journal the record (plan P6 step
|
||||
/// 2): a content-addressed text blob, the extractor and its version,
|
||||
/// the pages, and `ok`, `none` or `failed`. A file with no text still
|
||||
/// gets its `none` record: the books say reading was tried.
|
||||
fn extract_and_journal(
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
bytes: &[u8],
|
||||
mime_type: &str,
|
||||
file_name: &str,
|
||||
) -> Result<Extraction, StoreError> {
|
||||
if let Some(already) = self.extraction_for(attachment_id, E_EXTRACTOR, E_VERSION)? {
|
||||
return Ok(already);
|
||||
}
|
||||
let got = ledger_import::extract::extract(bytes, mime_type, file_name);
|
||||
let text_sha = match &got.text {
|
||||
Some(text) => Some(self.blobs.put(text.as_bytes())?.0),
|
||||
None => None,
|
||||
};
|
||||
let line = self.append_journal(
|
||||
"attachment.text_extracted",
|
||||
crate::meta::text_extracted(
|
||||
attachment_id,
|
||||
text_sha.as_deref(),
|
||||
E_EXTRACTOR,
|
||||
E_VERSION,
|
||||
got.pages,
|
||||
got.status.key(),
|
||||
),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(Extraction {
|
||||
attachment_id,
|
||||
text_sha256: text_sha,
|
||||
extractor: E_EXTRACTOR.into(),
|
||||
version: E_VERSION.into(),
|
||||
pages: got.pages,
|
||||
status: match got.status {
|
||||
ledger_import::extract::ExtractStatus::Ok => ExtractionStatus::Ok,
|
||||
ledger_import::extract::ExtractStatus::None => ExtractionStatus::None,
|
||||
ledger_import::extract::ExtractStatus::Failed => ExtractionStatus::Failed,
|
||||
},
|
||||
seq: line.seq,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an attachment's text now and journal it (plan P6 step 2;
|
||||
/// idempotent on attachment, extractor and version). A refusal (an
|
||||
/// unknown attachment, books that haven't declared `attachment_text`)
|
||||
/// journals nothing. The backfill
|
||||
/// (`wanderbooks books extract ID`) loops this over old attachments,
|
||||
/// one at a time.
|
||||
pub fn extract_attachment(&mut self, attachment_id: u64) -> Result<Extraction, StoreError> {
|
||||
let a = self
|
||||
.attachment_by_id(attachment_id)?
|
||||
.ok_or(StoreError::UnknownAttachment(attachment_id))?;
|
||||
if let Some(already) = self.extraction_for(attachment_id, E_EXTRACTOR, E_VERSION)? {
|
||||
return Ok(already);
|
||||
}
|
||||
self.ensure_feature(crate::format::ATTACHMENT_TEXT)?;
|
||||
let bytes = self.blobs.get(&a.blob_sha)?;
|
||||
self.extract_and_journal(attachment_id, &bytes, &a.mime_type, &a.file_name)
|
||||
}
|
||||
|
||||
/// The newest extraction record for an attachment, if any.
|
||||
pub fn extraction(&self, attachment_id: u64) -> Result<Option<Extraction>, StoreError> {
|
||||
Ok(query_extractions(&self.conn)?
|
||||
.into_iter()
|
||||
.filter(|x| x.attachment_id == attachment_id)
|
||||
.max_by_key(|x| x.seq))
|
||||
}
|
||||
|
||||
/// The extraction record for exactly this extractor and version.
|
||||
fn extraction_for(
|
||||
&self,
|
||||
attachment_id: u64,
|
||||
extractor: &str,
|
||||
version: &str,
|
||||
) -> Result<Option<Extraction>, StoreError> {
|
||||
Ok(query_extractions(&self.conn)?.into_iter().find(|x| {
|
||||
x.attachment_id == attachment_id && x.extractor == extractor && x.version == version
|
||||
}))
|
||||
}
|
||||
|
||||
/// The text an attachment's newest extraction read, if any.
|
||||
pub fn extraction_text(&self, attachment_id: u64) -> Result<Option<String>, StoreError> {
|
||||
let Some(x) = self.extraction(attachment_id)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(sha) = x.text_sha256 else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(
|
||||
String::from_utf8_lossy(&self.blobs.get(&sha)?).into_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Every extraction record, in seq order.
|
||||
pub fn extractions(&self) -> Result<Vec<Extraction>, StoreError> {
|
||||
query_extractions(&self.conn)
|
||||
}
|
||||
|
||||
/// Every annotation, by attachment id (plan P6 step 5).
|
||||
pub fn annotations(&self) -> Result<Vec<ledger_core::Annotation>, StoreError> {
|
||||
query_annotations(&self.conn)
|
||||
}
|
||||
|
||||
/// An attachment by id, withdrawn or not.
|
||||
pub fn attachment_by_id(&self, id: u64) -> Result<Option<Attachment>, StoreError> {
|
||||
Ok(query_attachments(&self.conn, None)?
|
||||
.into_iter()
|
||||
.find(|a| a.id == id))
|
||||
}
|
||||
|
||||
/// Attachments whose bytes hold `text`, case-insensitively: the text
|
||||
/// search of `books.find_attachment` (plan P6 step 2's extraction is
|
||||
/// what makes a receipt searchable by what it says).
|
||||
pub fn attachments_holding_text(&self, text: &str) -> Result<Vec<Attachment>, StoreError> {
|
||||
let needle = text.to_lowercase();
|
||||
let mut out = Vec::new();
|
||||
for a in query_attachments(&self.conn, None)? {
|
||||
if self
|
||||
.extraction_text(a.id)?
|
||||
.is_some_and(|t| t.to_lowercase().contains(&needle))
|
||||
{
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Attachments annotated with `receipt_number`.
|
||||
pub fn attachments_with_receipt_number(
|
||||
&self,
|
||||
receipt_number: u32,
|
||||
) -> Result<Vec<Attachment>, StoreError> {
|
||||
let ids: Vec<i64> = self
|
||||
.conn
|
||||
.prepare("SELECT attachment_id FROM annotations WHERE receipt_number = ?")?
|
||||
.query_map(params![i64::from(receipt_number)], |r| r.get(0))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(query_attachments(&self.conn, None)?
|
||||
.into_iter()
|
||||
.filter(|a| ids.contains(&(a.id as i64)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Record what a human read off a file (`attachment.annotated`, plan
|
||||
/// P6 step 5), replacing an earlier annotation of the same file. The
|
||||
/// fields are the closed list of [`ledger_core::AnnotationFields`];
|
||||
/// a refusal journals nothing.
|
||||
pub fn annotate_attachment(
|
||||
&mut self,
|
||||
annotation: ledger_core::Annotation,
|
||||
) -> Result<ledger_core::Annotation, StoreError> {
|
||||
self.ensure_feature(crate::format::ATTACHMENT_TEXT)?;
|
||||
let a = self
|
||||
.attachment_by_id(annotation.attachment_id)?
|
||||
.ok_or(StoreError::UnknownAttachment(annotation.attachment_id))?;
|
||||
if a.withdrawn_at.is_some() {
|
||||
return Err(StoreError::AttachmentWithdrawn(a.id));
|
||||
}
|
||||
let annotation = annotation.checked()?;
|
||||
self.rehearse(|l| l.annotate_attachment(annotation.clone()))?;
|
||||
let line =
|
||||
self.append_journal("attachment.annotated", crate::meta::annotated(&annotation))?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
self.ledger.annotate_attachment(annotation.clone())?;
|
||||
Ok(annotation)
|
||||
}
|
||||
|
||||
/// Record which entries an entry settles (`entry.settles`, plan P6
|
||||
/// step 5): what a reimbursement names, and the only source of a
|
||||
/// member-paid entry's "reimbursed" (IZ-18).
|
||||
pub fn set_entry_settles(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
settles: &[EntryId],
|
||||
) -> Result<(), StoreError> {
|
||||
self.ensure_feature(crate::format::ATTACHMENT_TEXT)?;
|
||||
self.rehearse(|l| l.set_entry_settles(entry, settles.to_vec()))?;
|
||||
let line =
|
||||
self.append_journal("entry.settles", crate::meta::entry_settles(entry, settles))?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
self.ledger.set_entry_settles(entry, settles.to_vec())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register an amortization schedule. Journaled as `schedule.registered`,
|
||||
/// with its `convention` (`daily` or `monthly_even`). A daily schedule
|
||||
/// needs journal feature `schedule_convention` (refused, journaling
|
||||
@@ -4424,6 +4710,32 @@ impl Store {
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
reason: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
self.withdraw_attachment_as(attachment_id, reason, None)
|
||||
}
|
||||
|
||||
/// [`Self::withdraw_attachment`] of a duplicate, naming the attachment
|
||||
/// it duplicates (`attachment.withdrawn`'s `duplicate_of`, plan P6
|
||||
/// step 4).
|
||||
pub fn withdraw_attachment_duplicate(
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
reason: &str,
|
||||
duplicate_of: u64,
|
||||
) -> Result<(), StoreError> {
|
||||
if duplicate_of == attachment_id {
|
||||
return Err(StoreError::Ledger(LedgerError::InvalidEvidence(
|
||||
"an attachment doesn't duplicate itself".into(),
|
||||
)));
|
||||
}
|
||||
self.withdraw_attachment_as(attachment_id, reason, Some(duplicate_of))
|
||||
}
|
||||
|
||||
fn withdraw_attachment_as(
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
reason: &str,
|
||||
duplicate_of: Option<u64>,
|
||||
) -> Result<(), StoreError> {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() {
|
||||
@@ -4433,9 +4745,21 @@ impl Store {
|
||||
if let Some(sub) = self.attachment_links(attachment_id)?.first() {
|
||||
return Err(StoreError::AlreadyLinked(attachment_id, sub.describe()));
|
||||
}
|
||||
if let Some(dup) = duplicate_of {
|
||||
// The one it duplicates must be a different attachment, and
|
||||
// `duplicate_of` is a field only books with the feature read
|
||||
// (plan P6).
|
||||
self.ensure_feature(crate::format::ATTACHMENT_TEXT)?;
|
||||
self.attachment_by_id(dup)?
|
||||
.ok_or(StoreError::UnknownAttachment(dup))?;
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"attachment.withdrawn",
|
||||
json!({ "attachment_id": attachment_id, "reason": reason }),
|
||||
json!({
|
||||
"attachment_id": attachment_id,
|
||||
"reason": reason,
|
||||
"duplicate_of": duplicate_of,
|
||||
}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
@@ -5094,6 +5418,20 @@ impl Store {
|
||||
"payroll runs diverged between journal, live ledger, and sqlite projection".into(),
|
||||
));
|
||||
}
|
||||
// The text an extraction recorded is a stored file too: present,
|
||||
// and the hash it names (plan P6 step 2). It is never re-derived
|
||||
// here (an extractor upgrade journals a new record, never replaces
|
||||
// one).
|
||||
for x in query_extractions(&self.conn)? {
|
||||
if let Some(sha) = x.text_sha256 {
|
||||
self.blobs.get(&sha).map_err(|e| {
|
||||
StoreError::VerifyMismatch(format!(
|
||||
"attachment {}'s extracted text doesn't verify: {e}",
|
||||
x.attachment_id
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
// Finally, the whole projection, not just what reports read: every
|
||||
// table, row for row, against a fresh projection of the journal.
|
||||
compare_projections(
|
||||
@@ -5153,7 +5491,9 @@ impl Store {
|
||||
/// The SHA-256 of every stored file the books name (receipts and
|
||||
/// documents, withdrawn ones included).
|
||||
pub fn stored_blob_shas(&self) -> Result<Vec<String>, StoreError> {
|
||||
let mut stmt = self.conn.prepare("SELECT sha256 FROM blobs")?;
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT sha256 FROM blobs UNION SELECT text_sha256 FROM extractions WHERE text_sha256 IS NOT NULL",
|
||||
)?;
|
||||
let shas = stmt
|
||||
.query_map([], |r| r.get::<_, String>(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
@@ -6160,8 +6500,22 @@ fn replay_line(
|
||||
| "receipt.linked"
|
||||
| "receipt.unlinked"
|
||||
| "document.filed"
|
||||
| "attachment.withdrawn" => {
|
||||
// Not accounting state; the projection records them.
|
||||
| "attachment.withdrawn"
|
||||
| "attachment.text_extracted" => {
|
||||
// Not accounting state; the projection records them. (The text
|
||||
// record is checked for shape here: an unknown status is newer,
|
||||
// rules.md B-27.)
|
||||
if line.line_type == "attachment.text_extracted" {
|
||||
crate::meta::text_extracted_of(line)?;
|
||||
}
|
||||
}
|
||||
"attachment.annotated" => {
|
||||
let a = crate::meta::annotated_of(line)?;
|
||||
ledger.replay_annotate_attachment(a)?;
|
||||
}
|
||||
"entry.settles" => {
|
||||
let (entry, settles) = crate::meta::entry_settles_of(p)?;
|
||||
ledger.replay_set_entry_settles(entry, settles)?;
|
||||
}
|
||||
"payroll.taxes_remitted" => {
|
||||
// Names the remittance entry (rebuilt by the entry events just
|
||||
@@ -7243,14 +7597,56 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
}
|
||||
"attachment.withdrawn" => {
|
||||
conn.execute(
|
||||
"UPDATE attachments SET withdrawn_at = ?, withdrawn_reason = ? WHERE id = ?",
|
||||
"UPDATE attachments SET withdrawn_at = ?, withdrawn_reason = ?, duplicate_of = ? WHERE id = ?",
|
||||
params![
|
||||
line.ts,
|
||||
get_str(p, "reason")?,
|
||||
p.get("duplicate_of").and_then(|v| v.as_u64()).map(|v| v as i64),
|
||||
get_u64(p, "attachment_id")? as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"attachment.text_extracted" => {
|
||||
let (id, sha, extractor, version, pages, status) =
|
||||
crate::meta::text_extracted_of(line)?;
|
||||
conn.execute(
|
||||
"INSERT INTO extractions(attachment_id, extractor, version, text_sha256, pages, status, seq)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT(attachment_id, extractor, version) DO UPDATE SET
|
||||
text_sha256 = excluded.text_sha256, pages = excluded.pages,
|
||||
status = excluded.status, seq = excluded.seq",
|
||||
params![id as i64, extractor, version, sha, i64::from(pages), status, line.seq as i64],
|
||||
)?;
|
||||
}
|
||||
"attachment.annotated" => {
|
||||
let a = crate::meta::annotated_of(line)?;
|
||||
conn.execute(
|
||||
"INSERT INTO annotations(attachment_id, receipt_number, payee, amount, date, instrument, category, source, reimbursable_to, note, seq)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(attachment_id) DO UPDATE SET
|
||||
receipt_number = excluded.receipt_number, payee = excluded.payee,
|
||||
amount = excluded.amount, date = excluded.date,
|
||||
instrument = excluded.instrument, category = excluded.category,
|
||||
source = excluded.source, reimbursable_to = excluded.reimbursable_to,
|
||||
note = excluded.note, seq = excluded.seq",
|
||||
params![
|
||||
a.attachment_id as i64,
|
||||
a.fields.receipt_number.map(i64::from),
|
||||
a.fields.payee,
|
||||
a.fields.amount.map(|v| v.to_string()),
|
||||
a.fields.date.map(|d| d.to_string()),
|
||||
a.fields.instrument,
|
||||
a.fields.category,
|
||||
a.fields.source,
|
||||
a.fields.reimbursable_to,
|
||||
a.note,
|
||||
line.seq as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"entry.settles" => {
|
||||
// Rebuilt into the ledger by replay; no report rows.
|
||||
}
|
||||
"asset.registered" => {
|
||||
let cost = &p["cost"];
|
||||
let salvage = &p["salvage_value"];
|
||||
@@ -7993,7 +8389,8 @@ pub(crate) fn query_attachments(
|
||||
let mut sql = String::from(
|
||||
"SELECT a.id, a.blob_sha, a.entry_id, a.category, \
|
||||
COALESCE(a.file_name, b.original_name), COALESCE(a.mime_type, b.mime_type), \
|
||||
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
|
||||
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason, \
|
||||
a.duplicate_of \
|
||||
FROM attachments a \
|
||||
JOIN blobs b ON b.sha256 = a.blob_sha",
|
||||
);
|
||||
@@ -8044,9 +8441,61 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
|
||||
created_at: r.get(8)?,
|
||||
withdrawn_at: r.get(9)?,
|
||||
withdrawn_reason: r.get(10)?,
|
||||
duplicate_of: r.get::<_, Option<i64>>(11)?.map(|v| v as u64),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every extraction record, in seq order (plan P6).
|
||||
pub(crate) fn query_extractions(conn: &Connection) -> Result<Vec<Extraction>, StoreError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT attachment_id, text_sha256, extractor, version, pages, status, seq \
|
||||
FROM extractions ORDER BY seq, attachment_id, extractor, version",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let status: String = r.get(5)?;
|
||||
Ok(Extraction {
|
||||
attachment_id: r.get::<_, i64>(0)? as u64,
|
||||
text_sha256: r.get(1)?,
|
||||
extractor: r.get(2)?,
|
||||
version: r.get(3)?,
|
||||
pages: r.get::<_, i64>(4)? as u32,
|
||||
status: ExtractionStatus::parse(&status).unwrap_or(ExtractionStatus::Failed),
|
||||
seq: r.get::<_, i64>(6)? as u64,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// Every annotation, by attachment id (plan P6).
|
||||
pub(crate) fn query_annotations(
|
||||
conn: &Connection,
|
||||
) -> Result<Vec<ledger_core::Annotation>, StoreError> {
|
||||
use ledger_core::{Annotation, AnnotationFields};
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT attachment_id, receipt_number, payee, amount, date, instrument, category, \
|
||||
source, reimbursable_to, note FROM annotations ORDER BY attachment_id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let amount: Option<String> = r.get(3)?;
|
||||
let date: Option<String> = r.get(4)?;
|
||||
Ok(Annotation {
|
||||
attachment_id: r.get::<_, i64>(0)? as u64,
|
||||
fields: AnnotationFields {
|
||||
receipt_number: r.get::<_, Option<i64>>(1)?.map(|v| v as u32),
|
||||
payee: r.get(2)?,
|
||||
amount: amount.and_then(|a| a.parse().ok()),
|
||||
date: date.and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()),
|
||||
instrument: r.get(5)?,
|
||||
category: r.get(6)?,
|
||||
source: r.get(7)?,
|
||||
reimbursable_to: r.get(8)?,
|
||||
},
|
||||
note: r.get(9)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// The base currency of a journal: its first line's, which must be a
|
||||
/// sound `store.created` ([`created_currency`]).
|
||||
pub(crate) fn journal_currency(lines: &[JournalLine]) -> Result<Currency, StoreError> {
|
||||
|
||||
Reference in new issue
Block a user