diff options
| -rw-r--r-- | Makefile | 10 | ||||
| -rw-r--r-- | docs/SCHEMA.md | 16 | ||||
| -rw-r--r-- | docs/STATE.md | 7 | ||||
| -rw-r--r-- | src/db.c | 247 | ||||
| -rw-r--r-- | src/db.h | 2 | ||||
| -rw-r--r-- | src/ledger.c | 46 | ||||
| -rw-r--r-- | src/ledger.h | 1 |
7 files changed, 303 insertions, 26 deletions
@@ -18,7 +18,8 @@ VENDOR_SRC = vendor/sqlite3.c vendor/yyjson.c vendor/sha256.c \ CORE_SRC = src/util.c src/log.c src/config.c src/db.c src/auth.c \ src/sessions.c src/audit.c src/protocol.c src/commands.c \ - src/seed.c src/ledger.c src/reports.c src/sru.c src/sie.c src/formula.c + src/seed.c src/ledger.c src/reports.c src/sru.c src/sie.c \ + src/formula.c src/pdf.c VENDOR_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(VENDOR_SRC)) CORE_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRC)) @@ -52,9 +53,14 @@ $(BUILD)/test_tui: $(BUILD)/tests/test_tui.o $(BUILD)/clients/tui.o \ $(BUILD)/vendor/sha256.o $(CC) $(CFLAGS) -o $@ $^ -lm -lncursesw -test: check $(BUILD)/test_core $(BUILD)/test_tui +$(BUILD)/test_pdf: $(BUILD)/tests/pdf_check.o $(BUILD)/src/pdf.o \ + $(BUILD)/src/util.o $(BUILD)/vendor/sha256.o + $(CC) $(CFLAGS) -o $@ $^ -lm + +test: check $(BUILD)/test_core $(BUILD)/test_tui $(BUILD)/test_pdf $(BUILD)/test_core $(BUILD)/test_tui + $(BUILD)/test_pdf test-core: $(BUILD)/test_core $(BUILD)/test_core diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 0f360d5..15e375a 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -202,7 +202,8 @@ CREATE TABLE vouchers ( date TEXT NOT NULL CHECK (date LIKE '____-__-__'), description TEXT NOT NULL CHECK (length(description) > 0), source TEXT NOT NULL DEFAULT 'manual' - CHECK (source IN ('manual','agent','sie_import','system','ib')), + CHECK (source IN ('manual','agent','sie_import','system','ib', + 'invoice')), client_ref TEXT, corrects_voucher_id INTEGER, created_at TEXT NOT NULL, @@ -576,7 +577,18 @@ another voucher is posted in between) — clients must not persist it. (thousands of vouchers per year are trivial). Attachments dominate growth; they live in the same file so a snapshot remains a single artifact. -## 14. Seeds +## 14. Invoicing (schema v9) + +`customers`, `invoice_sequence`, `invoices` and `invoice_rows` hold the +customer register, the per-org global invoice number series and issued +invoices with their rows. They are business documents, not ledger data: +invoices are written once at issue and only their status and send fields +change afterwards. The DDL and field semantics are in `docs/INVOICING.md` +§6. `vouchers.source` gained `'invoice'`; widening that CHECK required +rebuilding the table in the v9 migration (foreign keys are disabled for the +migration and `PRAGMA foreign_key_check` runs before they are re-enabled). + +## 15. Seeds | Data | Source | Notes | |---|---|---| diff --git a/docs/STATE.md b/docs/STATE.md index 97d0e8d..428bb79 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -280,9 +280,10 @@ check. - Never commit unless the human asks. - SQLite files must not be backed up live with restic; use `backup.snapshot` (`VACUUM INTO`) and point restic at the snapshots. -- Schema version is 7 (v3 moms rules; v4/v6 year info; v5 org - description/shares + board members; v7 attachments append-only triggers); - forward migrations are in `db.c`. +- Schema version is 9 (v3 moms rules; v4/v6 year info; v5 org + description/shares + board members; v7 attachments append-only triggers; + v8 bank reconciliation; v9 invoicing + widened `vouchers.source` with a + table rebuild); forward migrations are in `db.c`. ## Makandra driftstatus (org 2) @@ -144,7 +144,8 @@ static const char SCHEMA_V1[] = " date TEXT NOT NULL CHECK (date LIKE '____-__-__')," " description TEXT NOT NULL CHECK (length(description) > 0)," " source TEXT NOT NULL DEFAULT 'manual'" - " CHECK (source IN ('manual','agent','sie_import','system','ib'))," + " CHECK (source IN ('manual','agent','sie_import','system','ib'," + " 'invoice'))," " client_ref TEXT," " corrects_voucher_id INTEGER," " created_at TEXT NOT NULL," @@ -304,6 +305,81 @@ static const char SCHEMA_V1[] = " FOREIGN KEY (org_id, transaction_id)" " REFERENCES bank_transactions(org_id, id)," " FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id)" + ") STRICT;\n" + + "CREATE TABLE customers (" + " org_id INTEGER NOT NULL REFERENCES orgs(id)," + " id INTEGER PRIMARY KEY," + " name TEXT NOT NULL," + " address TEXT NOT NULL DEFAULT ''," + " postal_code TEXT NOT NULL DEFAULT ''," + " city TEXT NOT NULL DEFAULT ''," + " country TEXT NOT NULL DEFAULT 'SE'," + " vat_nr TEXT NOT NULL DEFAULT ''," + " email TEXT NOT NULL DEFAULT ''," + " your_ref TEXT NOT NULL DEFAULT ''," + " payment_days INTEGER NOT NULL DEFAULT 30 CHECK (payment_days >= 0)," + " notes TEXT NOT NULL DEFAULT ''," + " active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1))," + " created_at TEXT NOT NULL," + " updated_at TEXT," + " UNIQUE (org_id, id)," + " UNIQUE (org_id, name)" + ") STRICT;\n" + + "CREATE TABLE invoice_sequence (" + " org_id INTEGER NOT NULL PRIMARY KEY REFERENCES orgs(id)," + " next_number INTEGER NOT NULL CHECK (next_number > 0)" + ") STRICT;\n" + + "CREATE TABLE invoices (" + " org_id INTEGER NOT NULL REFERENCES orgs(id)," + " id INTEGER PRIMARY KEY," + " customer_id INTEGER NOT NULL," + " number INTEGER NOT NULL CHECK (number > 0)," + " ocr TEXT NOT NULL," + " invoice_date TEXT NOT NULL," + " due_date TEXT NOT NULL," + " delivery_date TEXT NOT NULL DEFAULT ''," + " your_ref TEXT NOT NULL DEFAULT ''," + " our_ref TEXT NOT NULL DEFAULT ''," + " notes TEXT NOT NULL DEFAULT ''," + " net_ore INTEGER NOT NULL," + " vat_ore INTEGER NOT NULL," + " total_ore INTEGER NOT NULL," + " status TEXT NOT NULL DEFAULT 'issued'" + " CHECK (status IN ('issued','credited'))," + " document_id INTEGER," + " voucher_id INTEGER," + " last_sent_at TEXT," + " last_sent_to TEXT," + " created_at TEXT NOT NULL," + " created_by INTEGER NOT NULL REFERENCES users(id)," + " UNIQUE (org_id, id)," + " UNIQUE (org_id, number)," + " FOREIGN KEY (org_id, customer_id) REFERENCES customers(org_id, id)," + " FOREIGN KEY (org_id, document_id) REFERENCES attachments(org_id, id)," + " FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id)" + ") STRICT;\n" + + "CREATE TABLE invoice_rows (" + " org_id INTEGER NOT NULL," + " id INTEGER PRIMARY KEY," + " invoice_id INTEGER NOT NULL," + " line_no INTEGER NOT NULL," + " article_no TEXT NOT NULL DEFAULT ''," + " description TEXT NOT NULL," + " quantity_milli INTEGER NOT NULL CHECK (quantity_milli > 0)," + " unit TEXT NOT NULL DEFAULT 'st'," + " unit_price_ore INTEGER NOT NULL," + " amount_ore INTEGER NOT NULL," + " note TEXT NOT NULL DEFAULT ''," + " vat_code TEXT NOT NULL DEFAULT '25'" + " CHECK (vat_code IN ('25','12','6','0','rc','eu'))," + " account TEXT NOT NULL DEFAULT ''," + " UNIQUE (org_id, id)," + " UNIQUE (org_id, invoice_id, line_no)," + " FOREIGN KEY (org_id, invoice_id) REFERENCES invoices(org_id, id)" ") STRICT;\n"; static const char SCHEMA_V2[] = @@ -535,6 +611,137 @@ static int db_upgrade_v8(sqlite3 *db, char **err) err); } +/* v9: invoicing — customers, the global invoice number series, invoices and + their rows, plus a widened voucher source. A CHECK cannot be altered in + place, so the vouchers table is rebuilt; db_open turns foreign keys off + for the migration and runs PRAGMA foreign_key_check afterwards. */ +static int db_upgrade_v9(sqlite3 *db, char **err) +{ + if (db_exec(db, + "CREATE TABLE IF NOT EXISTS customers (" + " org_id INTEGER NOT NULL REFERENCES orgs(id)," + " id INTEGER PRIMARY KEY," + " name TEXT NOT NULL," + " address TEXT NOT NULL DEFAULT ''," + " postal_code TEXT NOT NULL DEFAULT ''," + " city TEXT NOT NULL DEFAULT ''," + " country TEXT NOT NULL DEFAULT 'SE'," + " vat_nr TEXT NOT NULL DEFAULT ''," + " email TEXT NOT NULL DEFAULT ''," + " your_ref TEXT NOT NULL DEFAULT ''," + " payment_days INTEGER NOT NULL DEFAULT 30" + " CHECK (payment_days >= 0)," + " notes TEXT NOT NULL DEFAULT ''," + " active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1))," + " created_at TEXT NOT NULL," + " updated_at TEXT," + " UNIQUE (org_id, id)," + " UNIQUE (org_id, name)" + ") STRICT;" + "CREATE TABLE IF NOT EXISTS invoice_sequence (" + " org_id INTEGER NOT NULL PRIMARY KEY REFERENCES orgs(id)," + " next_number INTEGER NOT NULL CHECK (next_number > 0)" + ") STRICT;" + "CREATE TABLE IF NOT EXISTS invoices (" + " org_id INTEGER NOT NULL REFERENCES orgs(id)," + " id INTEGER PRIMARY KEY," + " customer_id INTEGER NOT NULL," + " number INTEGER NOT NULL CHECK (number > 0)," + " ocr TEXT NOT NULL," + " invoice_date TEXT NOT NULL," + " due_date TEXT NOT NULL," + " delivery_date TEXT NOT NULL DEFAULT ''," + " your_ref TEXT NOT NULL DEFAULT ''," + " our_ref TEXT NOT NULL DEFAULT ''," + " notes TEXT NOT NULL DEFAULT ''," + " net_ore INTEGER NOT NULL," + " vat_ore INTEGER NOT NULL," + " total_ore INTEGER NOT NULL," + " status TEXT NOT NULL DEFAULT 'issued'" + " CHECK (status IN ('issued','credited'))," + " document_id INTEGER," + " voucher_id INTEGER," + " last_sent_at TEXT," + " last_sent_to TEXT," + " created_at TEXT NOT NULL," + " created_by INTEGER NOT NULL REFERENCES users(id)," + " UNIQUE (org_id, id)," + " UNIQUE (org_id, number)," + " FOREIGN KEY (org_id, customer_id)" + " REFERENCES customers(org_id, id)," + " FOREIGN KEY (org_id, document_id)" + " REFERENCES attachments(org_id, id)," + " FOREIGN KEY (org_id, voucher_id)" + " REFERENCES vouchers(org_id, id)" + ") STRICT;" + "CREATE TABLE IF NOT EXISTS invoice_rows (" + " org_id INTEGER NOT NULL," + " id INTEGER PRIMARY KEY," + " invoice_id INTEGER NOT NULL," + " line_no INTEGER NOT NULL," + " article_no TEXT NOT NULL DEFAULT ''," + " description TEXT NOT NULL," + " quantity_milli INTEGER NOT NULL CHECK (quantity_milli > 0)," + " unit TEXT NOT NULL DEFAULT 'st'," + " unit_price_ore INTEGER NOT NULL," + " amount_ore INTEGER NOT NULL," + " note TEXT NOT NULL DEFAULT ''," + " vat_code TEXT NOT NULL DEFAULT '25'" + " CHECK (vat_code IN ('25','12','6','0','rc','eu'))," + " account TEXT NOT NULL DEFAULT ''," + " UNIQUE (org_id, id)," + " UNIQUE (org_id, invoice_id, line_no)," + " FOREIGN KEY (org_id, invoice_id)" + " REFERENCES invoices(org_id, id)" + ") STRICT", + err) != 0) + return -1; + return db_exec( + db, + "CREATE TABLE vouchers_new (" + " org_id INTEGER NOT NULL REFERENCES orgs(id)," + " id INTEGER PRIMARY KEY," + " fiscal_year_id INTEGER NOT NULL," + " series TEXT NOT NULL," + " number INTEGER NOT NULL CHECK (number > 0)," + " date TEXT NOT NULL CHECK (date LIKE '____-__-__')," + " description TEXT NOT NULL CHECK (length(description) > 0)," + " source TEXT NOT NULL DEFAULT 'manual'" + " CHECK (source IN ('manual','agent','sie_import','system','ib'," + " 'invoice'))," + " client_ref TEXT," + " corrects_voucher_id INTEGER," + " created_at TEXT NOT NULL," + " created_by_user INTEGER NOT NULL REFERENCES users(id)," + " created_by_token INTEGER REFERENCES api_tokens(id)," + " hash_prev BLOB NOT NULL CHECK (length(hash_prev) = 32)," + " hash BLOB NOT NULL CHECK (length(hash) = 32)," + " UNIQUE (org_id, id)," + " UNIQUE (org_id, fiscal_year_id, series, number)," + " UNIQUE (org_id, client_ref)," + " FOREIGN KEY (org_id, fiscal_year_id)" + " REFERENCES fiscal_years(org_id, id)," + " FOREIGN KEY (org_id, corrects_voucher_id)" + " REFERENCES vouchers(org_id, id)" + ") STRICT;" + "INSERT INTO vouchers_new(org_id,id,fiscal_year_id,series,number,date," + " description,source,client_ref,corrects_voucher_id,created_at," + " created_by_user,created_by_token,hash_prev,hash)" + " SELECT org_id,id,fiscal_year_id,series,number,date,description,source," + " client_ref,corrects_voucher_id,created_at,created_by_user," + " created_by_token,hash_prev,hash FROM vouchers;" + "DROP TABLE vouchers;" + "ALTER TABLE vouchers_new RENAME TO vouchers;" + "CREATE INDEX idx_vouchers_date ON vouchers(org_id, date);" + "CREATE INDEX idx_vouchers_fy" + " ON vouchers(org_id, fiscal_year_id, series, number);" + "CREATE TRIGGER vouchers_no_update BEFORE UPDATE ON vouchers" + " BEGIN SELECT RAISE(ABORT, 'vouchers are append-only'); END;" + "CREATE TRIGGER vouchers_no_delete BEFORE DELETE ON vouchers" + " BEGIN SELECT RAISE(ABORT, 'vouchers are append-only'); END;", + err); +} + static int db_upgrade(sqlite3 *db, int from, char **err) { if (db_exec(db, "BEGIN IMMEDIATE", err) != 0) @@ -567,6 +774,10 @@ static int db_upgrade(sqlite3 *db, int from, char **err) db_exec(db, "ROLLBACK", NULL); return -1; } + if (from < 9 && db_upgrade_v9(db, err) != 0) { + db_exec(db, "ROLLBACK", NULL); + return -1; + } char *sql = sqlite3_mprintf( "UPDATE meta SET value='%d' WHERE key='schema_version'", BOKF_SCHEMA_VERSION); @@ -651,6 +862,26 @@ static int pre_migration_snapshot(sqlite3 *db, int old_version, char **err) return 0; } +static int db_foreign_key_check(sqlite3 *db, char **err) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, "PRAGMA foreign_key_check", -1, &st, NULL) != + SQLITE_OK) { + set_err(err, "database error"); + return -1; + } + if (sqlite3_step(st) == SQLITE_ROW) { + const char *table = (const char *)sqlite3_column_text(st, 0); + int64_t rowid = sqlite3_column_int64(st, 1); + set_err(err, "foreign key violation in %s row %lld after migration", + table ? table : "?", (long long)rowid); + sqlite3_finalize(st); + return -1; + } + sqlite3_finalize(st); + return 0; +} + int db_open(const char *path, sqlite3 **out, char **err) { sqlite3 *db = NULL; @@ -694,10 +925,24 @@ int db_open(const char *path, sqlite3 **out, char **err) sqlite3_close(db); return -1; } + /* v9 rebuilds the vouchers table; foreign keys are re-checked + right after the migration and re-enabled for normal use */ + if (db_exec(db, "PRAGMA foreign_keys=OFF", err) != 0) { + sqlite3_close(db); + return -1; + } if (db_upgrade(db, version, err) != 0) { sqlite3_close(db); return -1; } + if (db_foreign_key_check(db, err) != 0) { + sqlite3_close(db); + return -1; + } + if (db_exec(db, "PRAGMA foreign_keys=ON", err) != 0) { + sqlite3_close(db); + return -1; + } } } else { sqlite3_finalize(st); @@ -4,7 +4,7 @@ #include <sqlite3.h> #include <stdint.h> -#define BOKF_SCHEMA_VERSION 8 +#define BOKF_SCHEMA_VERSION 9 int db_open(const char *path, sqlite3 **out, char **err); int db_migrate(sqlite3 *db, char **err); diff --git a/src/ledger.c b/src/ledger.c index 2de794e..a22e15e 100644 --- a/src/ledger.c +++ b/src/ledger.c @@ -345,6 +345,12 @@ static int db_step(sqlite3 *db, sqlite3_stmt *st, char *errbuf, size_t n) return rc; } +static void tx_rollback(sqlite3 *db, int owner) +{ + if (owner) + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); +} + int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, struct ledger_error *e, char **out_result_json) { @@ -417,9 +423,12 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, (long long)(sum_debit - sum_credit)); } - if (sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { - free(series_owned); - return fail(e, "DB_BUSY", "could not start transaction"); + if (!o->already_in_tx) { + if (sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != + SQLITE_OK) { + free(series_owned); + return fail(e, "DB_BUSY", "could not start transaction"); + } } int rc = -1; @@ -443,7 +452,7 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, sqlite3_bind_text(st, 2, o->date, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); free(series_owned); return fail(e, "DATE_OUT_OF_RANGE", "no fiscal year contains %s; open one first", o->date); @@ -462,13 +471,13 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, sqlite3_finalize(st); if (strcmp(fy_status, "closed") == 0) { - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); free(series_owned); return fail(e, "FISCAL_YEAR_CLOSED", "fiscal year %s is closed", fy_label); } if (locked_until[0] && strcmp(o->date, locked_until) <= 0) { - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); free(series_owned); return fail(e, "PERIOD_LOCKED", "period is locked through %s", locked_until); @@ -490,7 +499,7 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); free(acct_ids); - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); free(series_owned); return fail(e, "ACCOUNT_NOT_FOUND", "account %s does not exist", o->rows[i].account); @@ -500,7 +509,7 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, sqlite3_finalize(st); if (!active) { free(acct_ids); - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); free(series_owned); return fail(e, "ACCOUNT_INACTIVE", "account %s is inactive", o->rows[i].account); @@ -524,7 +533,7 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, sqlite3_finalize(st); if (!exists) { free(acct_ids); - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); free(series_owned); return fail(e, "NOT_FOUND", "voucher %lld does not exist", (long long)o->corrects_voucher_id); @@ -624,7 +633,7 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, *out_result_json = yyjson_mut_write(doc, 0, NULL); yyjson_mut_doc_free(doc); free(acct_ids); - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); free(series_owned); return 0; } @@ -722,13 +731,13 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, } sqlite3_finalize(st); if (!exists) { - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); free(series_owned); return fail(e, "NOT_FOUND", "attachment %lld does not exist", (long long)aid); } if (linked) { - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); free(series_owned); return fail(e, "CONFLICT", "attachment %lld is already linked", (long long)aid); @@ -783,10 +792,13 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, } } - if (sqlite3_exec(db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { - free(result_json); - free(series_owned); - return fail(e, "DB_BUSY", "commit failed: %s", sqlite3_errmsg(db)); + if (!o->already_in_tx) { + if (sqlite3_exec(db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { + free(result_json); + free(series_owned); + return fail(e, "DB_BUSY", "commit failed: %s", + sqlite3_errmsg(db)); + } } free(series_owned); *out_result_json = result_json; @@ -794,7 +806,7 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, busy: free(series_owned); - sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + tx_rollback(db, !o->already_in_tx); e->code = "DB_BUSY"; if (!e->msg[0]) snprintf(e->msg, sizeof e->msg, "database error: %s", diff --git a/src/ledger.h b/src/ledger.h index cf8f9b1..dee8be0 100644 --- a/src/ledger.h +++ b/src/ledger.h @@ -34,6 +34,7 @@ struct ledger_post_opts { const char *client_ref; /* NULL = none */ int dry_run; const char *source; /* NULL -> "manual" or "agent" */ + int already_in_tx; /* caller owns the transaction (no BEGIN/COMMIT) */ }; int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, |
