diff options
Diffstat (limited to 'src/ledger.c')
| -rw-r--r-- | src/ledger.c | 615 |
1 files changed, 615 insertions, 0 deletions
diff --git a/src/ledger.c b/src/ledger.c new file mode 100644 index 0000000..72402f0 --- /dev/null +++ b/src/ledger.c @@ -0,0 +1,615 @@ +#include "ledger.h" + +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "db.h" +#include "util.h" +#include "yyjson.h" + +static int fail(struct ledger_error *e, const char *code, const char *fmt, ...) + __attribute__((format(printf, 3, 4))); + +static int fail(struct ledger_error *e, const char *code, const char *fmt, ...) +{ + e->code = code; + va_list ap; + va_start(ap, fmt); + vsnprintf(e->msg, sizeof e->msg, fmt, ap); + va_end(ap); + return -1; +} + +static void append_str16(struct buf *b, const char *s) +{ + size_t n = s ? strlen(s) : 0; + if (n > 0xffff) + n = 0xffff; + buf_append_u16be(b, (uint16_t)n); + buf_append(b, s ? s : "", n); +} + +static void append_str32(struct buf *b, const char *s) +{ + size_t n = s ? strlen(s) : 0; + buf_append_u32be(b, (uint32_t)n); + buf_append(b, s ? s : "", n); +} + +void ledger_voucher_hash(const unsigned char prev[32], int64_t org_id, + const char *fy_label, const char *series, + int64_t number, const char *date, + const char *description, + const struct ledger_row *rows, size_t nrows, + unsigned char out[32]) +{ + struct buf b; + buf_init(&b); + buf_append(&b, "bokf-v1-voucher\0", 16); + buf_append(&b, prev, 32); + buf_append_u64be(&b, (uint64_t)org_id); + append_str16(&b, fy_label); + append_str16(&b, series); + buf_append_u64be(&b, (uint64_t)number); + buf_append(&b, date, strlen(date)); + append_str32(&b, description); + buf_append_u32be(&b, (uint32_t)nrows); + for (size_t i = 0; i < nrows; i++) { + append_str16(&b, rows[i].account); + buf_append_u64be(&b, (uint64_t)rows[i].debit_ore); + buf_append_u64be(&b, (uint64_t)rows[i].credit_ore); + append_str32(&b, rows[i].description); + } + util_sha256(b.p, b.len, out); + buf_free(&b); +} + +static int idempotent_lookup(sqlite3 *db, int64_t org_id, const char *client_ref, + char **out_json) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT response_json FROM idempotency WHERE org_id=?1" + " AND client_ref=?2", + -1, &st, NULL) != SQLITE_OK) + return 0; + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_text(st, 2, client_ref, -1, SQLITE_TRANSIENT); + int found = 0; + if (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char *p = sqlite3_column_text(st, 0); + if (p) { + yyjson_doc *d = yyjson_read((const char *)p, strlen((const char *)p), 0); + if (d && yyjson_is_obj(yyjson_doc_get_root(d))) { + yyjson_mut_doc *m = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = + yyjson_val_mut_copy(m, yyjson_doc_get_root(d)); + yyjson_mut_doc_set_root(m, root); + yyjson_mut_obj_add_bool(m, root, "replayed", true); + *out_json = yyjson_mut_write(m, 0, NULL); + yyjson_mut_doc_free(m); + found = 1; + } + if (d) + yyjson_doc_free(d); + } + } + sqlite3_finalize(st); + return found; +} + +static yyjson_mut_val *build_result(yyjson_mut_doc *doc, int64_t id, + int64_t org_id, int64_t fy_id, + const char *series, int64_t number, + const char *date, const char *description, + const char *source, + int64_t corrects_voucher_id, + const struct ledger_row *rows, + size_t nrows, + const int64_t *attachment_ids, + size_t n_attachments, + const unsigned char hash_prev[32], + const unsigned char hash[32], int dry_run, + const char *created_at) +{ + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, o, "id", id); + yyjson_mut_obj_add_int(doc, o, "org_id", org_id); + yyjson_mut_obj_add_int(doc, o, "fiscal_year_id", fy_id); + yyjson_mut_obj_add_strcpy(doc, o, "series", series); + yyjson_mut_obj_add_int(doc, o, "number", number); + yyjson_mut_obj_add_strcpy(doc, o, "date", date); + yyjson_mut_obj_add_strcpy(doc, o, "description", description); + yyjson_mut_obj_add_strcpy(doc, o, "source", source); + yyjson_mut_obj_add_strcpy(doc, o, "created_at", created_at); + if (corrects_voucher_id) + yyjson_mut_obj_add_int(doc, o, "corrects_voucher_id", + corrects_voucher_id); + else + yyjson_mut_obj_add_null(doc, o, "corrects_voucher_id"); + yyjson_mut_val *rarr = yyjson_mut_arr(doc); + for (size_t i = 0; i < nrows; i++) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(doc, rarr); + yyjson_mut_obj_add_int(doc, ro, "line_no", (int64_t)i + 1); + yyjson_mut_obj_add_strcpy(doc, ro, "account", rows[i].account); + yyjson_mut_obj_add_int(doc, ro, "debit_ore", rows[i].debit_ore); + yyjson_mut_obj_add_int(doc, ro, "credit_ore", rows[i].credit_ore); + if (rows[i].description) + yyjson_mut_obj_add_strcpy(doc, ro, "description", + rows[i].description); + else + yyjson_mut_obj_add_null(doc, ro, "description"); + } + yyjson_mut_obj_add_val(doc, o, "rows", rarr); + yyjson_mut_val *aarr = yyjson_mut_arr(doc); + for (size_t i = 0; i < n_attachments; i++) + yyjson_mut_arr_add_int(doc, aarr, attachment_ids[i]); + yyjson_mut_obj_add_val(doc, o, "attachment_ids", aarr); + char hex[65]; + util_hex(hash_prev, 32, hex); + yyjson_mut_obj_add_strcpy(doc, o, "hash_prev", hex); + util_hex(hash, 32, hex); + yyjson_mut_obj_add_strcpy(doc, o, "hash", hex); + if (dry_run) + yyjson_mut_obj_add_bool(doc, o, "dry_run", true); + return o; +} + +static const char *source_for(const struct ledger_post_opts *o) +{ + if (o->source) + return o->source; + return o->token_id ? "agent" : "manual"; +} + +/* sqlite3_step wrapper that captures the message before finalize clears it */ +static int db_step(sqlite3 *db, sqlite3_stmt *st, char *errbuf, size_t n) +{ + int rc = sqlite3_step(st); + if (rc != SQLITE_DONE && errbuf && !errbuf[0]) + snprintf(errbuf, n, "%s", sqlite3_errmsg(db)); + return rc; +} + +int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, + struct ledger_error *e, char **out_result_json) +{ + memset(e, 0, sizeof *e); + *out_result_json = NULL; + + char *series_owned = NULL; + const char *series = o->series; + if (!series || !*series) { + series_owned = db_setting(db, o->org_id, "default_series"); + series = series_owned && *series_owned ? series_owned : "A"; + } + if (!util_parse_iso_date(o->date)) { + free(series_owned); + return fail(e, "INVALID_ARGS", "date must be YYYY-MM-DD"); + } + if (!o->description || !*o->description) { + free(series_owned); + return fail(e, "INVALID_ARGS", "description is required"); + } + if (!o->rows || o->nrows < 2) { + free(series_owned); + return fail(e, "INVALID_ARGS", "at least two rows are required"); + } + if (strlen(series) > 8) { + free(series_owned); + return fail(e, "INVALID_ARGS", "series is too long"); + } + if (o->client_ref && strlen(o->client_ref) > 64) { + free(series_owned); + return fail(e, "INVALID_ARGS", "client_ref is too long"); + } + + if (o->client_ref && *o->client_ref) { + char *replay = NULL; + if (idempotent_lookup(db, o->org_id, o->client_ref, &replay)) { + *out_result_json = replay; + free(series_owned); + return 0; + } + } + + int64_t sum_debit = 0, sum_credit = 0; + for (size_t i = 0; i < o->nrows; i++) { + const struct ledger_row *r = &o->rows[i]; + if (!r->account || !*r->account) + return fail(e, "INVALID_ARGS", "row %zu: account is required", + i + 1); + if (r->debit_ore < 0 || r->credit_ore < 0) + return fail(e, "INVALID_ARGS", + "row %zu: amounts must be positive öre", i + 1); + if ((r->debit_ore == 0) == (r->credit_ore == 0)) + return fail(e, "INVALID_ARGS", + "row %zu: exactly one of debit/credit must be set", + i + 1); + sum_debit += r->debit_ore; + sum_credit += r->credit_ore; + } + if (sum_debit != sum_credit) { + e->has_details = 1; + e->difference_ore = sum_debit - sum_credit; + return fail(e, "UNBALANCED", "debit and credit differ by %lld öre", + (long long)(sum_debit - sum_credit)); + } + + if (sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) + return fail(e, "DB_BUSY", "could not start transaction"); + + int rc = -1; + sqlite3_stmt *st = NULL; + + /* fiscal year */ + int64_t fy_id = 0; + char fy_label[64] = ""; + char fy_start[16] = "", fy_end[16] = ""; + char fy_status[16] = ""; + char locked_until[16] = ""; + rc = sqlite3_prepare_v2( + db, + "SELECT id,label,start_date,end_date,status," + "COALESCE(locked_until,'') FROM fiscal_years" + " WHERE org_id=?1 AND start_date<=?2 AND end_date>=?2", + -1, &st, NULL); + if (rc != SQLITE_OK) + goto busy; + sqlite3_bind_int64(st, 1, o->org_id); + 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); + return fail(e, "DATE_OUT_OF_RANGE", + "no fiscal year contains %s; open one first", o->date); + } + fy_id = sqlite3_column_int64(st, 0); + snprintf(fy_label, sizeof fy_label, "%s", + (const char *)sqlite3_column_text(st, 1)); + snprintf(fy_start, sizeof fy_start, "%s", + (const char *)sqlite3_column_text(st, 2)); + snprintf(fy_end, sizeof fy_end, "%s", + (const char *)sqlite3_column_text(st, 3)); + snprintf(fy_status, sizeof fy_status, "%s", + (const char *)sqlite3_column_text(st, 4)); + snprintf(locked_until, sizeof locked_until, "%s", + (const char *)sqlite3_column_text(st, 5)); + sqlite3_finalize(st); + + if (strcmp(fy_status, "closed") == 0) { + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + 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); + return fail(e, "PERIOD_LOCKED", "period is locked through %s", + locked_until); + } + + /* resolve accounts */ + int64_t *acct_ids = xcalloc(o->nrows, sizeof(int64_t)); + for (size_t i = 0; i < o->nrows; i++) { + rc = sqlite3_prepare_v2( + db, + "SELECT id,active FROM accounts WHERE org_id=?1 AND number=?2", -1, + &st, NULL); + if (rc != SQLITE_OK) { + free(acct_ids); + goto busy; + } + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_text(st, 2, o->rows[i].account, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + free(acct_ids); + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + return fail(e, "ACCOUNT_NOT_FOUND", "account %s does not exist", + o->rows[i].account); + } + int active = sqlite3_column_int(st, 1); + acct_ids[i] = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + if (!active) { + free(acct_ids); + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + return fail(e, "ACCOUNT_INACTIVE", "account %s is inactive", + o->rows[i].account); + } + } + + if (o->corrects_voucher_id) { + rc = sqlite3_prepare_v2( + db, + "SELECT count(*) FROM vouchers WHERE org_id=?1 AND id=?2", -1, &st, + NULL); + if (rc != SQLITE_OK) { + free(acct_ids); + goto busy; + } + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_int64(st, 2, o->corrects_voucher_id); + int64_t exists = sqlite3_step(st) == SQLITE_ROW + ? sqlite3_column_int64(st, 0) + : 0; + sqlite3_finalize(st); + if (!exists) { + free(acct_ids); + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + return fail(e, "NOT_FOUND", "voucher %lld does not exist", + (long long)o->corrects_voucher_id); + } + } + + /* number sequence */ + rc = sqlite3_prepare_v2( + db, + "SELECT next_number FROM sequences WHERE org_id=?1" + " AND fiscal_year_id=?2 AND series=?3", + -1, &st, NULL); + if (rc != SQLITE_OK) { + free(acct_ids); + goto busy; + } + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_int64(st, 2, fy_id); + sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); + int64_t number = 1; + if (sqlite3_step(st) == SQLITE_ROW) { + number = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + rc = sqlite3_prepare_v2( + db, + "UPDATE sequences SET next_number=next_number+1" + " WHERE org_id=?1 AND fiscal_year_id=?2 AND series=?3", + -1, &st, NULL); + if (rc != SQLITE_OK) { + free(acct_ids); + goto busy; + } + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_int64(st, 2, fy_id); + sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); + rc = db_step(db, st, e->msg, sizeof e->msg); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + free(acct_ids); + goto busy; + } + } else { + sqlite3_finalize(st); + rc = sqlite3_prepare_v2( + db, + "INSERT INTO sequences(org_id,fiscal_year_id,series,next_number)" + " VALUES(?1,?2,?3,2)", + -1, &st, NULL); + if (rc != SQLITE_OK) { + free(acct_ids); + goto busy; + } + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_int64(st, 2, fy_id); + sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); + rc = db_step(db, st, e->msg, sizeof e->msg); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + free(acct_ids); + goto busy; + } + } + + /* vouchers are chained per org in insertion order */ + unsigned char prev[32]; + memset(prev, 0, sizeof prev); + rc = sqlite3_prepare_v2( + db, "SELECT hash FROM vouchers WHERE org_id=?1 ORDER BY id DESC LIMIT 1", + -1, &st, NULL); + if (rc != SQLITE_OK) { + free(acct_ids); + goto busy; + } + sqlite3_bind_int64(st, 1, o->org_id); + if (sqlite3_step(st) == SQLITE_ROW) { + const void *b = sqlite3_column_blob(st, 0); + if (b && sqlite3_column_bytes(st, 0) == 32) + memcpy(prev, b, 32); + } + sqlite3_finalize(st); + + unsigned char hash[32]; + ledger_voucher_hash(prev, o->org_id, fy_label, series, number, o->date, + o->description, o->rows, o->nrows, hash); + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + const char *source = source_for(o); + + if (o->dry_run) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *res = build_result( + doc, 0, o->org_id, fy_id, series, number, o->date, o->description, + source, o->corrects_voucher_id, o->rows, o->nrows, + o->attachment_ids, o->n_attachments, prev, hash, 1, ts); + yyjson_mut_doc_set_root(doc, res); + *out_result_json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + free(acct_ids); + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + free(series_owned); + return 0; + } + + rc = sqlite3_prepare_v2( + db, + "INSERT INTO vouchers(org_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)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", + -1, &st, NULL); + if (rc != SQLITE_OK) { + free(acct_ids); + goto busy; + } + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_int64(st, 2, fy_id); + sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 4, number); + sqlite3_bind_text(st, 5, o->date, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, o->description, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, source, -1, SQLITE_TRANSIENT); + if (o->client_ref && *o->client_ref) + sqlite3_bind_text(st, 8, o->client_ref, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 8); + if (o->corrects_voucher_id) + sqlite3_bind_int64(st, 9, o->corrects_voucher_id); + else + sqlite3_bind_null(st, 9); + sqlite3_bind_text(st, 10, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 11, o->user_id); + if (o->token_id) + sqlite3_bind_int64(st, 12, o->token_id); + else + sqlite3_bind_null(st, 12); + sqlite3_bind_blob(st, 13, prev, 32, SQLITE_TRANSIENT); + sqlite3_bind_blob(st, 14, hash, 32, SQLITE_TRANSIENT); + rc = db_step(db, st, e->msg, sizeof e->msg); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + free(acct_ids); + goto busy; + } + int64_t voucher_id = sqlite3_last_insert_rowid(db); + + for (size_t i = 0; i < o->nrows; i++) { + rc = sqlite3_prepare_v2( + db, + "INSERT INTO voucher_rows(org_id,voucher_id,line_no,account_id," + "debit_ore,credit_ore,description)" + " VALUES(?1,?2,?3,?4,?5,?6,?7)", + -1, &st, NULL); + if (rc != SQLITE_OK) { + free(acct_ids); + goto busy; + } + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_int64(st, 3, (int64_t)i + 1); + sqlite3_bind_int64(st, 4, acct_ids[i]); + sqlite3_bind_int64(st, 5, o->rows[i].debit_ore); + sqlite3_bind_int64(st, 6, o->rows[i].credit_ore); + if (o->rows[i].description) + sqlite3_bind_text(st, 7, o->rows[i].description, -1, + SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 7); + rc = db_step(db, st, e->msg, sizeof e->msg); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + free(acct_ids); + goto busy; + } + } + free(acct_ids); + acct_ids = NULL; + + for (size_t i = 0; i < o->n_attachments; i++) { + int64_t aid = o->attachment_ids[i]; + rc = sqlite3_prepare_v2( + db, + "SELECT (SELECT count(*) FROM attachments WHERE org_id=?1 AND id=?2)," + "(SELECT count(*) FROM voucher_attachments WHERE org_id=?1 AND" + " attachment_id=?2)", + -1, &st, NULL); + if (rc != SQLITE_OK) + goto busy; + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_int64(st, 2, aid); + int linked = 0, exists = 0; + if (sqlite3_step(st) == SQLITE_ROW) { + exists = (int)sqlite3_column_int64(st, 0); + linked = (int)sqlite3_column_int64(st, 1); + } + sqlite3_finalize(st); + if (!exists) { + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + return fail(e, "NOT_FOUND", "attachment %lld does not exist", + (long long)aid); + } + if (linked) { + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + return fail(e, "CONFLICT", "attachment %lld is already linked", + (long long)aid); + } + rc = sqlite3_prepare_v2( + db, + "INSERT INTO voucher_attachments(org_id,voucher_id,attachment_id," + "created_at) VALUES(?1,?2,?3,?4)", + -1, &st, NULL); + if (rc != SQLITE_OK) + goto busy; + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_int64(st, 3, aid); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + rc = db_step(db, st, e->msg, sizeof e->msg); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + goto busy; + } + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *res = build_result( + doc, voucher_id, o->org_id, fy_id, series, number, o->date, + o->description, source, o->corrects_voucher_id, o->rows, o->nrows, + o->attachment_ids, o->n_attachments, prev, hash, 0, ts); + yyjson_mut_doc_set_root(doc, res); + char *result_json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + if (!result_json) + goto busy; + + if (o->client_ref && *o->client_ref) { + rc = sqlite3_prepare_v2( + db, + "INSERT INTO idempotency(org_id,client_ref,cmd,response_json," + "created_at) VALUES(?1,?2,'voucher.post',?3,?4)", + -1, &st, NULL); + if (rc != SQLITE_OK) { + free(result_json); + goto busy; + } + sqlite3_bind_int64(st, 1, o->org_id); + sqlite3_bind_text(st, 2, o->client_ref, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, result_json, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + rc = db_step(db, st, e->msg, sizeof e->msg); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + free(result_json); + goto busy; + } + } + + 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; + return 0; + +busy: + free(series_owned); + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + e->code = "DB_BUSY"; + if (!e->msg[0]) + snprintf(e->msg, sizeof e->msg, "database error: %s", + sqlite3_errmsg(db)); + return -1; +} |
