diff --git a/Documentation/cli.md b/Documentation/cli.md index 7aa8cb7..76ffca4 100644 --- a/Documentation/cli.md +++ b/Documentation/cli.md @@ -62,7 +62,11 @@ rpnc │ │ # 2. create any missing cards (pin/description nullable) │ │ # 3. create transactions │ │ # 4. create any missing batches -│ │ # 5. verify batch values match calculated value +│ │ # 5. verify batch values match calculated value: the +│ │ # file carries no totals of its own, so this is an +│ │ # internal check -- recompute each batch touched by +│ │ # the import from the transactions table and compare +│ │ # with the stored values │ │ # one import is one DB transaction: a step 5 mismatch │ │ # aborts and persists nothing (exit code 1) │ ├── list # list all files stored in DB @@ -96,7 +100,9 @@ rpnc │ # optional filters --customer, --from, --to │ └── transaction # immutable; created only via "file import"; no create/update/delete - ├── read # fetch transaction details + ├── read # fetch transaction details by : + # the register's receipt counter repeats across days, + # so the day + receipt pair is the business key └── list # list transactions # optional filters --customer, --card, --batch, --from, --to ``` @@ -120,8 +126,11 @@ rpnc ## ID semantics Positional `id` arguments take the business key of the entity, never a -surrogate key: customer number, card number, batch number, invoice id, or -filename, depending on the entity. +surrogate key: customer number, card number, batch number, invoice number, +or filename, depending on the entity. The exception is transactions, whose +business key is the pair day + receipt (`transaction read `): +the register's receipt counter repeats across days, and the pair is the +ledger's dedup key (verified unique in all samples). ## Status values - card: active / suspended / cancelled diff --git a/Documentation/schema.sql b/Documentation/schema.sql new file mode 100644 index 0000000..2ccde5d --- /dev/null +++ b/Documentation/schema.sql @@ -0,0 +1,184 @@ +-- Domain schema for rusty_petroleum (v1: fuel domain) +-- Optimized for MariaDB / InnoDB +-- +-- Scope: the six tables below plus invoice_items cover the v1 "in +-- development" features (epsilon ingest, invoices). Auth (users, +-- sessions, roles) and the tsdrms / subfranchise / voucher / car +-- registry domains arrive as later embedded migrations, per the +-- decisions recorded 2026-09. In the application this file becomes +-- the first embedded migration, applied by `db setup` behind the +-- schema_migrations table (see readme.md); the DROP block below is +-- the reset path (`db reset` drops the whole database). +-- +-- Design decisions (agreed with maintainer, 2026-09): +-- - cards.pin: NULL-able; created without pin on import. Stored +-- cleartext on purpose: customers retrieve it in the portal, and +-- the cards are only used on-site at the station, so the risk is +-- accepted. +-- - transactions is a SLIM PROJECTION (10 of 16 source fields): +-- QualityName, Station, Terminal, Pump, Card report group number +-- and Control number are dropped. The files table (full byte +-- content) is the canonical archive; the ledger keeps only what +-- application features query. +-- - Customer business key is the register customer number, stored as +-- a STRING in both customers.external_id and +-- transactions.customer_number (arrives as quoted text in the CSV; +-- always 4 digits today, but bookkeeping/Fortnox features cannot +-- guarantee numeric-only). NULL = retail/unknown (replaces the 0 +-- sentinel). +-- - Money is DECIMAL(10,2) SEK everywhere (VAT-inclusive as +-- delivered); the BIGINT-cents representation is dropped. +-- - Invoice status values are exactly cli.md's: draft / sent. Card +-- statuses: active / suspended / cancelled. (cli.md is the spec.) +-- - Invoices carry full source traceability: batch_id or date range, +-- per-transaction line items, and credit invoices reference the +-- invoice they correct. + +SET FOREIGN_KEY_CHECKS = 0; +DROP TABLE IF EXISTS invoice_items; +DROP TABLE IF EXISTS invoices; +DROP TABLE IF EXISTS transactions; +DROP TABLE IF EXISTS cards; +DROP TABLE IF EXISTS batches; +DROP TABLE IF EXISTS customers; +DROP TABLE IF EXISTS files; +SET FOREIGN_KEY_CHECKS = 1; + +-- 1. Imported source files +-- filename is the natural key (readme.md: filenames carry metadata and +-- make re-ingestion idempotent); checksum catches byte-identical +-- re-imports. content holds the full byte-faithful source so +-- `file export --format raw` works and the DB is the archive of +-- record. One file may span many batches (cumulative exports); +-- batch_number is set only for single-batch files. +CREATE TABLE files ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL, + format VARCHAR(20) NOT NULL, -- 'epsilon-tsv' (v1); tsdrms / subfranchise later + checksum VARCHAR(64) NOT NULL, -- SHA-256, dedup of byte-identical re-imports + content LONGTEXT NOT NULL, -- raw source, byte-faithful + row_count INT UNSIGNED NOT NULL, -- data rows parsed from the file + batch_number INT UNSIGNED, -- single-batch files only; NULL for cumulative + imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE INDEX idx_filename (filename), + UNIQUE INDEX idx_checksum (checksum) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. Customers (contract fuel customers) +-- Identity is the register customer number (business key). VARCHAR: +-- the number arrives as quoted text, is 4 digits in all samples, and +-- must not be assumed numeric-only when bookkeeping features arrive. +CREATE TABLE customers ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + external_id VARCHAR(100) NOT NULL, -- register customer number, as delivered + name VARCHAR(255) NOT NULL, + address TEXT, + email VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE INDEX idx_external_id (external_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3. Batches (derived entity: one per register batch; created and +-- maintained by file import, recomputed by `batch update`). +-- The batch number from the filename/rows is the business key. +-- Cached aggregates mirror transactions; no FK to files (the relation +-- is derivable via transactions.imported_file_id + batch_number). +CREATE TABLE batches ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + batch_number INT UNSIGNED NOT NULL, -- business key, from filename/rows + start_date DATE NOT NULL, -- min transaction date in the batch + end_date DATE NOT NULL, -- max transaction date in the batch + total_amount DECIMAL(10,2) NOT NULL,-- cached sum of amounts (SEK, VAT-incl.) + transaction_count INT UNSIGNED NOT NULL, -- cached row count + status VARCHAR(20) NOT NULL, -- open / closed / invoiced + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE INDEX idx_batch_number (batch_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 4. Cards (contract fuel cards only: epsilon rows with a customer +-- number; retail rows carry no card). One card per card number; the +-- same number may recur across batches. +CREATE TABLE cards ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + customer_id INT UNSIGNED NOT NULL, + card_number VARCHAR(50) NOT NULL, -- as delivered (unmasked for contract cards) + pin VARCHAR(20), -- NULL until known; cleartext by decision (on-site cards) + status VARCHAR(20) NOT NULL, -- active / suspended / cancelled + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE INDEX idx_card_number (card_number), + INDEX idx_customer_id (customer_id), + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 5. Transactions (the immutable ledger) +-- Created only by file import; no create/update/delete commands. +-- Slim projection of the 16 source fields (decision above); raw +-- values as delivered, so NO foreign keys to cards/customers -- +-- customer_number/card_number are raw strings, joinable by equality +-- with customers.external_id / cards.card_number. The only FK is +-- provenance: which file the row came from. +CREATE TABLE transactions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + transaction_date DATETIME NOT NULL, -- parsed from M/d/yyyy h:mm:ss AM/PM + amount DECIMAL(10,2) NOT NULL, -- SEK, VAT-inclusive; = volume x price + volume DECIMAL(10,2) NOT NULL, -- liters + price DECIMAL(10,2) NOT NULL, -- SEK/liter + quality INT NOT NULL, -- 1001 = unleaded ("95 Oktan"), 4 = diesel, 0 = zero-value + card_number VARCHAR(50) NOT NULL, -- as delivered (masked for consumer cards) + customer_number VARCHAR(100), -- as delivered; NULL = retail/unknown + receipt VARCHAR(20) NOT NULL, -- zero-padded at 6 digits in all samples; widened deliberately + batch_number INT UNSIGNED NOT NULL, -- matches the filename; always present + imported_file_id INT UNSIGNED NOT NULL, + UNIQUE INDEX idx_dedup (transaction_date, receipt), -- re-ingestion dedup; verified unique in the 138k-row sample + INDEX idx_batch_number (batch_number), + INDEX idx_customer_number (customer_number), + INDEX idx_card_number (card_number), + FOREIGN KEY (imported_file_id) REFERENCES files(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- (The old standalone idx_transaction_date was dropped: the unique +-- (transaction_date, receipt) prefix already serves date-range scans.) + +-- 6. Invoices (outgoing fuel invoices only; one buyer per invoice) +-- Created from a batch OR a date range, per customer ("all" fans out). +-- full traceability: batch_id and/or the effective period, plus one +-- line item per source transaction; credit invoices reference the +-- sent invoice they correct. Amounts DECIMAL SEK, VAT-inclusive; +-- the 25% base/VAT split is computed at creation. +CREATE TABLE invoices ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + invoice_number VARCHAR(100) NOT NULL, -- assigned by the app at creation + customer_id INT UNSIGNED NOT NULL, + batch_id INT UNSIGNED, -- set for --batch creation; NULL for date-range + from_date DATE NOT NULL, -- effective period start (batch dates or --from) + to_date DATE NOT NULL, -- effective period end (batch dates or --to) + issue_date DATE NOT NULL, + due_date DATE NOT NULL, + total_amount DECIMAL(10,2) NOT NULL, -- VAT-inclusive; negative for credit invoices + status VARCHAR(20) NOT NULL, -- draft / sent (v1) + original_invoice_id INT UNSIGNED, -- set on credit invoices; NULL otherwise + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE INDEX idx_invoice_number (invoice_number), + INDEX idx_customer_id (customer_id), + INDEX idx_batch_id (batch_id), + INDEX idx_issue_date (issue_date), + INDEX idx_original_invoice_id (original_invoice_id), + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT, + FOREIGN KEY (batch_id) REFERENCES batches(id) ON DELETE RESTRICT, + FOREIGN KEY (original_invoice_id) REFERENCES invoices(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 7. Invoice line items (one per source transaction) +-- description is derived at creation (quality code -> name lookup, +-- volume/price text); amount is the transaction amount, VAT-incl. +CREATE TABLE invoice_items ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + invoice_id INT UNSIGNED NOT NULL, + source_transaction_id BIGINT UNSIGNED NOT NULL, + description VARCHAR(255) NOT NULL, + amount DECIMAL(10,2) NOT NULL, + INDEX idx_invoice_id (invoice_id), + INDEX idx_source_transaction_id (source_transaction_id), + FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE, + FOREIGN KEY (source_transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/readme.md b/readme.md index a8db842..eb20fdf 100644 --- a/readme.md +++ b/readme.md @@ -37,7 +37,10 @@ not speed or efficiency. - Database: MariaDB (protocol-compatible MySQL). Client: sqlx, with mysql_async as fallback. The DB server itself is out of scope; given connection details in config, the application creates its own tables - from embedded migrations (schema_migrations table). + from embedded migrations (schema_migrations table). The v1 domain + schema (files, customers, batches, cards, transactions, invoices, + invoice_items) is documented in Documentation/schema.sql; it is the + initial embedded migration. - Config: TOML file (see config.template.toml), loaded via --config flag or $RUSTYRPN_CONFIG. Real config files are gitignored. Daemon state (pidfile, logs) and database backups default to