#include "ledger.h" #include #include #include #include #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 void verify_err(char **err, const char *msg) { if (err && !*err) *err = xstrdup(msg); } static char *copy_text(const unsigned char *p) { return xstrdup(p ? (const char *)p : ""); } static void free_rows(struct ledger_row *rows, size_t nrows) { for (size_t i = 0; i < nrows; i++) { free((char *)rows[i].account); free((char *)rows[i].description); } free(rows); } static int verify_org(sqlite3 *db, int64_t org_id, struct ledger_verify_result *out, char **err) { sqlite3_stmt *vs = NULL, *rs = NULL; if (sqlite3_prepare_v2( db, "SELECT v.id,v.hash_prev,v.hash,fy.label,v.series,v.number,v.date," "v.description FROM vouchers v JOIN fiscal_years fy" " ON fy.org_id=v.org_id AND fy.id=v.fiscal_year_id" " WHERE v.org_id=?1 ORDER BY v.id", -1, &vs, NULL) != SQLITE_OK) { verify_err(err, sqlite3_errmsg(db)); return -1; } if (sqlite3_prepare_v2( db, "SELECT a.number,r.debit_ore,r.credit_ore,r.description" " FROM voucher_rows r JOIN accounts a" " ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", -1, &rs, NULL) != SQLITE_OK) { sqlite3_finalize(vs); verify_err(err, sqlite3_errmsg(db)); return -1; } unsigned char running[32]; memset(running, 0, sizeof running); int ret = 0; sqlite3_bind_int64(vs, 1, org_id); for (;;) { int step = sqlite3_step(vs); if (step == SQLITE_DONE) break; if (step != SQLITE_ROW) { verify_err(err, sqlite3_errmsg(db)); ret = -1; break; } int64_t id = sqlite3_column_int64(vs, 0); const void *hprev = sqlite3_column_blob(vs, 1); int hprev_n = sqlite3_column_bytes(vs, 1); const void *hstored = sqlite3_column_blob(vs, 2); int hstored_n = sqlite3_column_bytes(vs, 2); char *label = copy_text(sqlite3_column_text(vs, 3)); char *series = copy_text(sqlite3_column_text(vs, 4)); int64_t number = sqlite3_column_int64(vs, 5); char *date = copy_text(sqlite3_column_text(vs, 6)); char *vdesc = copy_text(sqlite3_column_text(vs, 7)); struct ledger_row *rows = NULL; size_t nrows = 0, cap = 0; int64_t sum_debit = 0, sum_credit = 0; sqlite3_bind_int64(rs, 1, org_id); sqlite3_bind_int64(rs, 2, id); int rstep; while ((rstep = sqlite3_step(rs)) == SQLITE_ROW) { if (nrows == cap) { cap = cap ? cap * 2 : 8; rows = xrealloc(rows, cap * sizeof *rows); } rows[nrows].account = copy_text(sqlite3_column_text(rs, 0)); rows[nrows].debit_ore = sqlite3_column_int64(rs, 1); rows[nrows].credit_ore = sqlite3_column_int64(rs, 2); rows[nrows].description = copy_text(sqlite3_column_text(rs, 3)); sum_debit += rows[nrows].debit_ore; sum_credit += rows[nrows].credit_ore; nrows++; } sqlite3_reset(rs); sqlite3_clear_bindings(rs); if (rstep != SQLITE_DONE) { verify_err(err, sqlite3_errmsg(db)); free_rows(rows, nrows); free(label); free(series); free(date); free(vdesc); ret = -1; break; } unsigned char expect[32]; ledger_voucher_hash(running, org_id, label, series, number, date, vdesc, rows, nrows, expect); out->vouchers_checked++; if (sum_debit != sum_credit) { out->unbalanced_vouchers++; if (!out->first_unbalanced_voucher_id) out->first_unbalanced_voucher_id = id; if (ret == 0) ret = 2; } if (hprev_n != 32 || hstored_n != 32 || memcmp(hprev, running, 32) != 0 || memcmp(hstored, expect, 32) != 0) { out->first_bad_voucher_id = id; ret = 1; } else { memcpy(running, expect, 32); } free_rows(rows, nrows); free(label); free(series); free(date); free(vdesc); if (ret == 1) break; } sqlite3_finalize(vs); sqlite3_finalize(rs); return ret; } int ledger_verify(sqlite3 *db, struct ledger_verify_result *out, char **err) { memset(out, 0, sizeof *out); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(db, "SELECT DISTINCT org_id FROM vouchers" " ORDER BY org_id", -1, &st, NULL) != SQLITE_OK) { verify_err(err, sqlite3_errmsg(db)); return -1; } int rc = 0; for (;;) { int step = sqlite3_step(st); if (step == SQLITE_DONE) break; if (step != SQLITE_ROW) { verify_err(err, sqlite3_errmsg(db)); rc = -1; break; } int64_t org_id = sqlite3_column_int64(st, 0); int r = verify_org(db, org_id, out, err); if (r < 0) { rc = -1; break; } if (r == 1) { rc = 1; break; } if (r == 2) rc = 1; } sqlite3_finalize(st); return rc; } 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) { free(series_owned); return fail(e, "INVALID_ARGS", "row %zu: account is required", i + 1); } if (r->debit_ore < 0 || r->credit_ore < 0) { free(series_owned); return fail(e, "INVALID_ARGS", "row %zu: amounts must be positive öre", i + 1); } if ((r->debit_ore == 0) == (r->credit_ore == 0)) { free(series_owned); 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; free(series_owned); 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) { free(series_owned); 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); free(series_owned); 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); 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); free(series_owned); 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); free(series_owned); 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); free(series_owned); 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); free(series_owned); 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); 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); free(series_owned); 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; }