summaryrefslogtreecommitdiff
path: root/src/ledger.c
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-17 19:55:36 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-17 19:55:36 +0200
commit380195f7cd5e57acf2c1cf2bc41069e6b0b979ed (patch)
tree32a88fb22a7fbe8f1fd5c105156d1f928c93950d /src/ledger.c
downloadbokf-380195f7cd5e57acf2c1cf2bc41069e6b0b979ed.tar.gz
bokf-380195f7cd5e57acf2c1cf2bc41069e6b0b979ed.zip
Initial commit: daemon, clients, docs, Docker deploy pipelinev0.1.0
Diffstat (limited to 'src/ledger.c')
-rw-r--r--src/ledger.c615
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;
+}