#include "commands.h" #include "cmd_util.h" #include #include #include #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 = db_row_json(r->rdoc, st, "id:i,fiscal_year_id:i,series:s,number:i," "date:s,description:s,source:s," "corrects_voucher_id:r,created_at:s," "created_by_user:i"); 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 db_error(r); 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 db_error(r); 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 db_error(r); 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 db_error(r); 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 db_error(r); 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] };