aboutsummaryrefslogtreecommitdiff
path: root/src/cmd_vouchers.c
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 20:11:19 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 20:11:19 +0200
commit8351fa7752bb8341ef405a37ff7c73142715e981 (patch)
treedde603d313f4f26934f2ce20a22dfd9b69324cf0 /src/cmd_vouchers.c
parentbeafdbe0d64340387d619c38554976410129f05b (diff)
downloadbokf-8351fa7752bb8341ef405a37ff7c73142715e981.tar.gz
bokf-8351fa7752bb8341ef405a37ff7c73142715e981.zip
commands: split the command table by domain
Diffstat (limited to 'src/cmd_vouchers.c')
-rw-r--r--src/cmd_vouchers.c625
1 files changed, 625 insertions, 0 deletions
diff --git a/src/cmd_vouchers.c b/src/cmd_vouchers.c
new file mode 100644
index 0000000..3e30544
--- /dev/null
+++ b/src/cmd_vouchers.c
@@ -0,0 +1,625 @@
+#include "commands.h"
+#include "cmd_util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "audit.h"
+#include "db.h"
+#include "formula.h"
+#include "ledger.h"
+#include "util.h"
+
+/* ------------------------------------------------------------------ */
+/* vouchers */
+/* ------------------------------------------------------------------ */
+
+static int parse_rows(struct req *r, yyjson_val *rowsv, struct ledger_row **out,
+ size_t *out_n)
+{
+ if (!rowsv || !yyjson_is_arr(rowsv))
+ return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1;
+ size_t n = yyjson_arr_size(rowsv);
+ struct ledger_row *rows = xcalloc(n ? n : 1, sizeof *rows);
+ size_t k = 0;
+ yyjson_arr_iter it = yyjson_arr_iter_with(rowsv);
+ yyjson_val *item;
+ while ((item = yyjson_arr_iter_next(&it))) {
+ if (!yyjson_is_obj(item)) {
+ free(rows);
+ fail(r, "INVALID_ARGS", "each row must be an object");
+ return -1;
+ }
+ yyjson_val *av = yyjson_obj_get(item, "account");
+ if (!av || !yyjson_is_str(av)) {
+ free(rows);
+ failf(r, "INVALID_ARGS", "row %zu: account is required", k + 1);
+ return -1;
+ }
+ yyjson_val *dv = yyjson_obj_get(item, "debit_ore");
+ yyjson_val *cv = yyjson_obj_get(item, "credit_ore");
+ if ((dv && !yyjson_is_int(dv)) || (cv && !yyjson_is_int(cv))) {
+ free(rows);
+ failf(r, "INVALID_ARGS", "row %zu: amounts must be integer öre",
+ k + 1);
+ return -1;
+ }
+ rows[k].account = yyjson_get_str(av);
+ rows[k].debit_ore = dv ? yyjson_get_int(dv) : 0;
+ rows[k].credit_ore = cv ? yyjson_get_int(cv) : 0;
+ yyjson_val *d = yyjson_obj_get(item, "description");
+ rows[k].description = d && yyjson_is_str(d) ? yyjson_get_str(d) : NULL;
+ k++;
+ }
+ *out = rows;
+ *out_n = k;
+ return 0;
+}
+
+static int parse_attachment_ids(struct req *r, int64_t **out, size_t *out_n)
+{
+ yyjson_val *av = r->args ? yyjson_obj_get(r->args, "attachment_ids") : NULL;
+ *out = NULL;
+ *out_n = 0;
+ if (!av)
+ return 0;
+ if (!yyjson_is_arr(av))
+ return fail(r, "INVALID_ARGS", "attachment_ids must be an array") ? -1
+ : -1;
+ size_t n = yyjson_arr_size(av);
+ int64_t *ids = xcalloc(n ? n : 1, sizeof *ids);
+ size_t k = 0;
+ yyjson_arr_iter it = yyjson_arr_iter_with(av);
+ yyjson_val *item;
+ while ((item = yyjson_arr_iter_next(&it))) {
+ if (!yyjson_is_int(item)) {
+ free(ids);
+ fail(r, "INVALID_ARGS", "attachment_ids must be integers");
+ return -1;
+ }
+ ids[k++] = yyjson_get_int(item);
+ }
+ *out = ids;
+ *out_n = k;
+ return 0;
+}
+
+static yyjson_mut_val *h_voucher_post(struct req *r)
+{
+ const char *date = arg_str(r->args, "date");
+ const char *description = arg_str(r->args, "description");
+ const char *series = arg_str(r->args, "series");
+ const char *client_ref = arg_str(r->args, "client_ref");
+ int64_t corrects = 0;
+ arg_int(r->args, "corrects_voucher", &corrects);
+ if (!date)
+ return fail(r, "INVALID_ARGS", "date is required");
+
+ /* template application: {template, x} instead of {rows} */
+ int64_t tpl_id = 0;
+ const char *tpl_name = NULL;
+ yyjson_val *tv = r->args ? yyjson_obj_get(r->args, "template") : NULL;
+ if (tv) {
+ if (yyjson_is_int(tv))
+ tpl_id = yyjson_get_int(tv);
+ else if (yyjson_is_str(tv))
+ tpl_name = yyjson_get_str(tv);
+ else
+ return fail(r, "INVALID_ARGS", "template must be an id or a name");
+ if (r->args && yyjson_obj_get(r->args, "rows"))
+ return fail(r, "INVALID_ARGS",
+ "use either rows or template, not both");
+ }
+ double x = 0;
+ yyjson_val *xv = r->args ? yyjson_obj_get(r->args, "x") : NULL;
+ if (xv) {
+ if (yyjson_is_str(xv)) {
+ int ok = 0;
+ x = parse_kr_double(yyjson_get_str(xv), &ok);
+ if (!ok)
+ return fail(r, "INVALID_ARGS", "x must be a number in kronor");
+ } else if (yyjson_is_real(xv)) {
+ x = yyjson_get_real(xv);
+ } else if (yyjson_is_int(xv)) {
+ x = (double)yyjson_get_int(xv);
+ } else {
+ return fail(r, "INVALID_ARGS", "x must be a number in kronor");
+ }
+ }
+
+ struct ledger_row *rows = NULL;
+ size_t nrows = 0;
+ char *owned_desc = NULL;
+ char owned_series[16] = "";
+ int rows_owned = 0;
+
+ if (tv) {
+ struct tpl_head head;
+ char *err = NULL;
+ if (load_template(r->db, r->org_id, tpl_id, tpl_name, &head, &err) !=
+ 0) {
+ yyjson_mut_val *res =
+ fail(r, "NOT_FOUND", err ? err : "template not found");
+ free(err);
+ return res;
+ }
+ struct tpl_loaded *trows = NULL;
+ size_t ntrows = 0;
+ if (load_template_rows(r->db, r->org_id, head.id, &trows, &ntrows,
+ &err) != 0) {
+ yyjson_mut_val *res =
+ fail(r, "INTERNAL", err ? err : "could not load template");
+ free(err);
+ return res;
+ }
+ struct template_row *in =
+ xcalloc(ntrows ? ntrows : 1, sizeof *in);
+ for (size_t i = 0; i < ntrows; i++) {
+ in[i].account = trows[i].account;
+ in[i].formula = trows[i].formula;
+ in[i].description = trows[i].desc[0] ? trows[i].desc : NULL;
+ }
+ struct resolved_row *resolved =
+ xcalloc(ntrows ? ntrows : 1, sizeof *resolved);
+ char ferr[256] = "";
+ if (formula_resolve_rows(in, ntrows, x, resolved, &nrows, ferr,
+ sizeof ferr) != 0) {
+ free(in);
+ free(resolved);
+ free(trows);
+ return fail(r, "INVALID_ARGS",
+ ferr[0] ? ferr : "template could not be resolved");
+ }
+ free(in);
+ free(trows);
+ rows = xcalloc(nrows, sizeof *rows);
+ for (size_t i = 0; i < nrows; i++) {
+ rows[i].account = xstrdup(resolved[i].account);
+ rows[i].debit_ore = resolved[i].debit_ore;
+ rows[i].credit_ore = resolved[i].credit_ore;
+ rows[i].description =
+ resolved[i].description[0] ? xstrdup(resolved[i].description)
+ : NULL;
+ }
+ free(resolved);
+ rows_owned = 1;
+ if (!series && head.series[0])
+ snprintf(owned_series, sizeof owned_series, "%s", head.series);
+ if (!description && head.description[0])
+ owned_desc = replace_x(head.description, x);
+ if (!description && !owned_desc)
+ owned_desc = xstrdup(head.name);
+ } else {
+ if (!description)
+ return fail(r, "INVALID_ARGS",
+ "date and description are required");
+ if (parse_rows(r, r->args ? yyjson_obj_get(r->args, "rows") : NULL,
+ &rows, &nrows) != 0)
+ return NULL;
+ }
+
+ int64_t *att = NULL;
+ size_t natt = 0;
+ if (parse_attachment_ids(r, &att, &natt) != 0) {
+ if (rows_owned)
+ for (size_t i = 0; i < nrows; i++) {
+ free((char *)rows[i].account);
+ free((char *)rows[i].description);
+ }
+ free(rows);
+ free(owned_desc);
+ return NULL;
+ }
+
+ struct ledger_post_opts o;
+ memset(&o, 0, sizeof o);
+ o.org_id = r->org_id;
+ o.user_id = r->sess->user_id;
+ o.token_id = r->sess->token_id;
+ o.date = date;
+ o.description = description ? description : owned_desc;
+ o.series = series ? series
+ : (owned_series[0] ? owned_series : NULL);
+ o.rows = rows;
+ o.nrows = nrows;
+ o.corrects_voucher_id = corrects;
+ o.attachment_ids = att;
+ o.n_attachments = natt;
+ o.client_ref = client_ref;
+ o.dry_run = r->dry_run;
+
+ struct ledger_error e;
+ char *json = NULL;
+ int rc = ledger_post(r->db, &o, &e, &json);
+ if (rows_owned)
+ for (size_t i = 0; i < nrows; i++) {
+ free((char *)rows[i].account);
+ free((char *)rows[i].description);
+ }
+ free(rows);
+ free(att);
+ free(owned_desc);
+ if (rc != 0) {
+ if (e.has_details) {
+ yyjson_mut_val *details = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, details, "difference_ore",
+ e.difference_ore);
+ r->err_details = details;
+ }
+ return fail(r, e.code ? e.code : "INTERNAL", e.msg);
+ }
+
+ if (!r->dry_run) {
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "voucher.post", reqjson, "OK", NULL);
+ free(reqjson);
+ }
+ yyjson_mut_val *res = json_to_mut(r->rdoc, json);
+ free(json);
+ if (!res)
+ return fail(r, "INTERNAL", "could not serialize result");
+ return res;
+}
+
+static yyjson_mut_val *voucher_json(struct req *r, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id",
+ sqlite3_column_int64(st, 1));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "date",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description",
+ sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "source",
+ sq(sqlite3_column_text(st, 6)));
+ if (sqlite3_column_type(st, 7) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id",
+ sqlite3_column_int64(st, 7));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 8)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "created_by_user",
+ sqlite3_column_int64(st, 9));
+ char hex[65];
+ const void *hb = sqlite3_column_blob(st, 10);
+ if (hb && sqlite3_column_bytes(st, 10) == 32) {
+ util_hex((const unsigned char *)hb, 32, hex);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash_prev", hex);
+ }
+ const void *vb = sqlite3_column_blob(st, 11);
+ if (vb && sqlite3_column_bytes(st, 11) == 32) {
+ util_hex((const unsigned char *)vb, 32, hex);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash", hex);
+ }
+ return o;
+}
+
+#define VOUCHER_COLUMNS \
+ "id,fiscal_year_id,series,number,date,description,source," \
+ "corrects_voucher_id,created_at,created_by_user,hash_prev,hash"
+
+static yyjson_mut_val *h_voucher_get(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT " VOUCHER_COLUMNS " FROM vouchers WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "voucher not found");
+ }
+ yyjson_mut_val *o = voucher_json(r, st);
+ sqlite3_finalize(st);
+
+ yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc);
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT a.number,a.name,r.debit_ore,r.credit_ore,"
+ "COALESCE(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, &st, NULL) == SQLITE_OK) {
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, rows);
+ yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account",
+ sq(sqlite3_column_text(st, 0)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, ro, "name",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_int(r->rdoc, ro, "debit_ore",
+ sqlite3_column_int64(st, 2));
+ yyjson_mut_obj_add_int(r->rdoc, ro, "credit_ore",
+ sqlite3_column_int64(st, 3));
+ yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description",
+ sq(sqlite3_column_text(st, 4)));
+ }
+ sqlite3_finalize(st);
+ }
+ yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows);
+
+ yyjson_mut_val *atts = yyjson_mut_arr(r->rdoc);
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT a.id,a.filename FROM voucher_attachments va"
+ " JOIN attachments a ON a.org_id=va.org_id AND a.id=va.attachment_id"
+ " WHERE va.org_id=?1 AND va.voucher_id=?2 ORDER BY a.id",
+ -1, &st, NULL) == SQLITE_OK) {
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *ao = yyjson_mut_arr_add_obj(r->rdoc, atts);
+ yyjson_mut_obj_add_int(r->rdoc, ao, "id",
+ sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, ao, "filename",
+ sq(sqlite3_column_text(st, 1)));
+ }
+ sqlite3_finalize(st);
+ }
+ yyjson_mut_obj_add_val(r->rdoc, o, "attachments", atts);
+ return o;
+}
+
+static yyjson_mut_val *h_voucher_list(struct req *r)
+{
+ int64_t fy = 0, cursor = 0, limit = 100, account_id = 0;
+ arg_int(r->args, "fiscal_year", &fy);
+ arg_int(r->args, "cursor", &cursor);
+ arg_int(r->args, "limit", &limit);
+ if (limit < 1)
+ limit = 100;
+ if (limit > 1000)
+ limit = 1000;
+ const char *series = arg_str(r->args, "series");
+ const char *from = arg_str(r->args, "from");
+ const char *to = arg_str(r->args, "to");
+ const char *text = arg_str(r->args, "text");
+ const char *account = arg_str(r->args, "account");
+ if (account) {
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT id FROM accounts WHERE org_id=?1 AND number=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "account not found");
+ }
+ account_id = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ }
+
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT v.id,v.series,v.number,v.date,v.description,v.source,"
+ "v.corrects_voucher_id,"
+ "(SELECT count(*) FROM voucher_rows r WHERE r.org_id=v.org_id"
+ " AND r.voucher_id=v.id),"
+ "(SELECT count(*) FROM voucher_attachments va WHERE va.org_id=v.org_id"
+ " AND va.voucher_id=v.id)"
+ " FROM vouchers v WHERE v.org_id=?1"
+ " AND (?2=0 OR v.fiscal_year_id=?2)"
+ " AND (?3='' OR v.series=?3)"
+ " AND (?4='' OR v.date>=?4)"
+ " AND (?5='' OR v.date<=?5)"
+ " AND (?6=0 OR EXISTS(SELECT 1 FROM voucher_rows r2"
+ " WHERE r2.org_id=v.org_id AND r2.voucher_id=v.id"
+ " AND r2.account_id=?6))"
+ " AND (?7='' OR v.description LIKE '%'||?7||'%')"
+ " AND v.id>?8 ORDER BY v.id LIMIT ?9",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, fy);
+ sqlite3_bind_text(st, 3, series ? series : "", -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, from ? from : "", -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, to ? to : "", -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 6, account_id);
+ sqlite3_bind_text(st, 7, text ? text : "", -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 8, cursor);
+ sqlite3_bind_int64(st, 9, limit);
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ int64_t last = cursor, n = 0;
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ n++;
+ last = sqlite3_column_int64(st, 0);
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", last);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "number",
+ sqlite3_column_int64(st, 2));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "date",
+ sq(sqlite3_column_text(st, 3)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "source",
+ sq(sqlite3_column_text(st, 5)));
+ if (sqlite3_column_type(st, 6) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id",
+ sqlite3_column_int64(st, 6));
+ yyjson_mut_obj_add_int(r->rdoc, o, "row_count",
+ sqlite3_column_int64(st, 7));
+ yyjson_mut_obj_add_int(r->rdoc, o, "attachment_count",
+ sqlite3_column_int64(st, 8));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ if (n == limit)
+ yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor");
+ return out;
+}
+
+static yyjson_mut_val *h_voucher_correct(struct req *r)
+{
+ int64_t id = 0;
+ const char *description = arg_str(r->args, "description");
+ const char *date = arg_str(r->args, "date");
+ const char *client_ref = arg_str(r->args, "client_ref");
+ if (!arg_int(r->args, "voucher", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "voucher is required");
+ if (!description || !*description)
+ return fail(r, "INVALID_ARGS", "description is required");
+
+ sqlite3_stmt *st = NULL;
+ char series[16] = "", orig_date[16] = "";
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT series,date FROM vouchers WHERE org_id=?1 AND id=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "voucher not found");
+ }
+ snprintf(series, sizeof series, "%s", sqlite3_column_text(st, 0));
+ snprintf(orig_date, sizeof orig_date, "%s", sqlite3_column_text(st, 1));
+ sqlite3_finalize(st);
+
+ struct ledger_row *rows = NULL;
+ size_t nrows = 0, cap = 0;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT a.number,r.debit_ore,r.credit_ore,"
+ "COALESCE(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, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ if (nrows == cap) {
+ cap = cap ? cap * 2 : 8;
+ rows = xrealloc(rows, cap * sizeof *rows);
+ }
+ memset(&rows[nrows], 0, sizeof rows[nrows]);
+ rows[nrows].account = xstrdup(sq(sqlite3_column_text(st, 0)));
+ rows[nrows].debit_ore = sqlite3_column_int64(st, 2);
+ rows[nrows].credit_ore = sqlite3_column_int64(st, 1);
+ const unsigned char *d = sqlite3_column_text(st, 3);
+ rows[nrows].description = d && *d ? xstrdup((const char *)d) : NULL;
+ nrows++;
+ }
+ sqlite3_finalize(st);
+ if (nrows == 0)
+ return fail(r, "INTERNAL", "voucher has no rows");
+
+ struct ledger_post_opts o;
+ memset(&o, 0, sizeof o);
+ o.org_id = r->org_id;
+ o.user_id = r->sess->user_id;
+ o.token_id = r->sess->token_id;
+ o.date = date ? date : orig_date;
+ o.description = description;
+ o.series = series;
+ o.rows = rows;
+ o.nrows = nrows;
+ o.corrects_voucher_id = id;
+ o.client_ref = client_ref;
+ o.dry_run = r->dry_run;
+
+ struct ledger_error e;
+ char *json = NULL;
+ int rc = ledger_post(r->db, &o, &e, &json);
+ for (size_t i = 0; i < nrows; i++) {
+ free((char *)rows[i].account);
+ free((char *)rows[i].description);
+ }
+ free(rows);
+ if (rc != 0)
+ return fail(r, e.code ? e.code : "INTERNAL", e.msg);
+ if (!r->dry_run) {
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "voucher.correct", reqjson, "OK", NULL);
+ free(reqjson);
+ }
+ yyjson_mut_val *res = json_to_mut(r->rdoc, json);
+ free(json);
+ if (!res)
+ return fail(r, "INTERNAL", "could not serialize result");
+ return res;
+}
+
+
+static const struct cmd_arg args_voucher_post[] = {
+ { "date", ARG_DATE, 1, NULL, NULL, "Voucher date (YYYY-MM-DD)" },
+ { "description", ARG_STR, 0, NULL, NULL, "Voucher text" },
+ { "series", ARG_STR, 0, NULL, NULL,
+ "Number series; defaults to settings.default_series" },
+ { "client_ref", ARG_STR, 0, NULL, NULL,
+ "Idempotency key, unique per org" },
+ { "corrects_voucher", ARG_INT, 0, NULL, NULL,
+ "Voucher id this one corrects" },
+ { "rows", ARG_JSON, 0, NULL, NULL,
+ "Array of {account,debit_ore,credit_ore,description?}" },
+ { "template", ARG_JSON, 0, NULL, NULL,
+ "Template id or name; alternative to rows" },
+ { "x", ARG_JSON, 0, NULL, NULL, "Template variable in kronor" },
+ { "attachment_ids", ARG_JSON, 0, NULL, NULL,
+ "Array of attachment ids to link" },
+};
+
+static const struct cmd_arg args_voucher_get[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Voucher id" },
+};
+
+static const struct cmd_arg args_voucher_list[] = {
+ { "fiscal_year", ARG_INT, 0, NULL, NULL,
+ "Fiscal year id; defaults to the latest" },
+ { "from", ARG_DATE, 0, NULL, NULL, "Earliest date" },
+ { "to", ARG_DATE, 0, NULL, NULL, "Latest date" },
+ { "series", ARG_STR, 0, NULL, NULL, "Number series filter" },
+ { "account", ARG_STR, 0, NULL, NULL, "Account number filter" },
+ { "text", ARG_STR, 0, NULL, NULL, "Description substring filter" },
+ { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" },
+ { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" },
+};
+
+static const struct cmd_arg args_voucher_correct[] = {
+ { "voucher", ARG_INT, 1, NULL, NULL, "Voucher id to correct" },
+ { "description", ARG_STR, 1, NULL, NULL, "Correction text" },
+ { "date", ARG_DATE, 0, NULL, NULL, "Defaults to the original date" },
+ { "client_ref", ARG_STR, 0, NULL, NULL, "Idempotency key" },
+};
+
+const struct command g_cmd_vouchers[] = {
+ { "voucher.post", "Post an immutable voucher (rows or template+x)",
+ PERM_WRITE, 1, 1, 1, h_voucher_post, CMD_ARGS(args_voucher_post) },
+ { "voucher.get", "Get a voucher with rows", PERM_READ, 1, 0, 0,
+ h_voucher_get, CMD_ARGS(args_voucher_get) },
+ { "voucher.list", "List vouchers", PERM_READ, 1, 0, 0, h_voucher_list,
+ CMD_ARGS(args_voucher_list) },
+ { "voucher.correct", "Post an ändringsverifikat", PERM_WRITE, 1, 1, 1,
+ h_voucher_correct, CMD_ARGS(args_voucher_correct) },
+};
+
+const struct cmd_table g_cmd_table_vouchers = {
+ g_cmd_vouchers, sizeof g_cmd_vouchers / sizeof g_cmd_vouchers[0]
+};