#include "commands.h" #include "cmd_util.h" #include #include #include #include "audit.h" #include "db.h" #include "util.h" /* ------------------------------------------------------------------ */ /* fiscal years and period locks */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *fy_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_strcpy(r->rdoc, o, "label", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", sq(sqlite3_column_text(st, 4))); if (sqlite3_column_type(st, 5) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "locked_until"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", sqlite3_column_int64(st, 6)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "events", sq(sqlite3_column_text(st, 7))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "agm_date", sq(sqlite3_column_text(st, 8))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "dividend_date", sq(sqlite3_column_text(st, 9))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "employees", sq(sqlite3_column_text(st, 10))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", sq(sqlite3_column_text(st, 11))); return o; } #define FY_COLUMNS \ "id,label,start_date,end_date,status,locked_until,dividend_ore," \ "events,agm_date,dividend_date,employees,notes" static yyjson_mut_val *h_fiscal_year_list(struct req *r) { yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT " FY_COLUMNS " FROM fiscal_years WHERE org_id=?1" " ORDER BY start_date", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); while (sqlite3_step(st) == SQLITE_ROW) yyjson_mut_arr_add_val(items, fy_json(r, st)); sqlite3_finalize(st); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, o, "items", items); return o; } static yyjson_mut_val *h_fiscal_year_get(struct req *r) { int64_t id = 0; arg_int(r->args, "id", &id); sqlite3_stmt *st = NULL; const char *sql = id ? "SELECT " FY_COLUMNS " FROM fiscal_years" " WHERE org_id=?1 AND id=?2" : "SELECT " FY_COLUMNS " FROM fiscal_years" " WHERE org_id=?1 ORDER BY start_date DESC LIMIT 1"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); if (id) sqlite3_bind_int64(st, 2, id); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = fy_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "fiscal year not found"); return o; } static int fy_exists_in_range(sqlite3 *db, int64_t org_id, const char *start, const char *end) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( db, "SELECT count(*) FROM fiscal_years WHERE org_id=?1" " AND NOT (end_date < ?2 OR start_date > ?3)", -1, &st, NULL) != SQLITE_OK) return 1; sqlite3_bind_int64(st, 1, org_id); sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, end, -1, SQLITE_TRANSIENT); int64_t n = 0; if (sqlite3_step(st) == SQLITE_ROW) n = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return n > 0; } static yyjson_mut_val *h_fiscal_year_open(struct req *r) { const char *label = arg_str(r->args, "label"); const char *start = arg_str(r->args, "start_date"); const char *end = arg_str(r->args, "end_date"); if (!label || !*label) return fail(r, "INVALID_ARGS", "label is required"); if (!util_parse_iso_date(start) || !util_parse_iso_date(end)) return fail(r, "INVALID_ARGS", "start_date and end_date must be YYYY-MM-DD"); if (strcmp(start, end) >= 0) return fail(r, "INVALID_ARGS", "start_date must be before end_date"); if (fy_exists_in_range(r->db, r->org_id, start, end)) return fail(r, "CONFLICT", "fiscal year overlaps an existing one"); /* "Information om året" carries over from the latest earlier year, so only what changes needs to be edited. Dates and the dividend are year-specific and start empty. */ char prev_events[600] = "", prev_emp[64] = "", prev_notes[600] = ""; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT events,employees,notes FROM fiscal_years" " WHERE org_id=?1 AND end_date < ?2" " ORDER BY end_date DESC LIMIT 1", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) == SQLITE_ROW) { snprintf(prev_events, sizeof prev_events, "%s", (const char *)sqlite3_column_text(st, 0)); snprintf(prev_emp, sizeof prev_emp, "%s", (const char *)sqlite3_column_text(st, 1)); snprintf(prev_notes, sizeof prev_notes, "%s", (const char *)sqlite3_column_text(st, 2)); } sqlite3_finalize(st); } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); if (sqlite3_prepare_v2( r->db, "INSERT INTO fiscal_years(org_id,label,start_date,end_date," "events,employees,notes,created_at)" " VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, label, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, start, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, end, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, prev_events, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, prev_emp, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 7, prev_notes, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 8, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "CONFLICT", "could not create fiscal year"); int64_t id = db_last_id(r->db); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "fiscal_year.open", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label); yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", start); yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", end); yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "open"); return o; } static yyjson_mut_val *h_fiscal_year_close(struct req *r) { int64_t id = 0; int confirm = 0; arg_int(r->args, "id", &id); arg_bool(r->args, "confirm", &confirm); if (id <= 0 || !confirm) return fail(r, "INVALID_ARGS", "id and confirm:true are required"); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE fiscal_years SET status='closed', closed_at=?3," " closed_by=?4 WHERE org_id=?1 AND id=?2 AND status='open'", -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); sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 4, r->sess->user_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "open fiscal year not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "fiscal_year.close", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } static yyjson_mut_val *h_fiscal_year_reopen(struct req *r) { int64_t id = 0; int confirm = 0; arg_int(r->args, "id", &id); arg_bool(r->args, "confirm", &confirm); if (id <= 0 || !confirm) return fail(r, "INVALID_ARGS", "id and confirm:true are required"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE fiscal_years SET status='open', closed_at=NULL," " closed_by=NULL WHERE org_id=?1 AND id=?2 AND status='closed'", -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); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "closed fiscal year not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "fiscal_year.reopen", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } /* Fiscal-year metadata that is not a posting: the board's proposed dividend and the year's material events, kept for the årsredovisning draft. Both fields are optional; at least one must be given. */ static yyjson_mut_val *h_fiscal_year_update(struct req *r) { int64_t id = 0, dividend = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); int have_div = arg_int(r->args, "dividend_ore", ÷nd); const char *events = arg_str(r->args, "events"); const char *agm = arg_str(r->args, "agm_date"); const char *pay = arg_str(r->args, "dividend_date"); const char *employees = arg_str(r->args, "employees"); const char *notes = arg_str(r->args, "notes"); if (!have_div && !events && !agm && !pay && !employees && !notes) return fail(r, "INVALID_ARGS", "nothing to update"); if (have_div && dividend < 0) return fail(r, "INVALID_ARGS", "dividend_ore must be >= 0"); if (agm && *agm && !util_parse_iso_date(agm)) return fail(r, "INVALID_ARGS", "agm_date must be YYYY-MM-DD"); if (pay && *pay && !util_parse_iso_date(pay)) return fail(r, "INVALID_ARGS", "dividend_date must be YYYY-MM-DD"); static const char *const skeys[4] = { "events", "agm_date", "dividend_date", "employees" }; const char *svals[4] = { events, agm, pay, employees }; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT count(*) FROM fiscal_years" " 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); int64_t exists = 0; if (sqlite3_step(st) == SQLITE_ROW) exists = sqlite3_column_int64(st, 0); sqlite3_finalize(st); if (!exists) return fail(r, "NOT_FOUND", "fiscal year not found"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); if (have_div) yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", dividend); for (int i = 0; i < 4; i++) if (svals[i]) yyjson_mut_obj_add_strcpy(r->rdoc, o, skeys[i], svals[i]); if (notes) yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes); return o; } if (sqlite3_prepare_v2(r->db, "UPDATE fiscal_years SET" " dividend_ore=CASE WHEN ?3<0 THEN dividend_ore" " ELSE ?3 END," " events=COALESCE(?4,events)," " agm_date=COALESCE(?5,agm_date)," " dividend_date=COALESCE(?6,dividend_date)," " employees=COALESCE(?7,employees)," " notes=COALESCE(?8,notes)" " 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); sqlite3_bind_int64(st, 3, have_div ? dividend : -1); for (int i = 0; i < 4; i++) { if (svals[i]) sqlite3_bind_text(st, i + 4, svals[i], -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, i + 4); } if (notes) sqlite3_bind_text(st, 8, notes, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 8); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "fiscal_year.update", reqjson, "OK", NULL); free(reqjson); if (sqlite3_prepare_v2(r->db, "SELECT " FY_COLUMNS " FROM fiscal_years" " 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); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = fy_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "fiscal year not found"); return o; } static yyjson_mut_val *h_period_lock(struct req *r) { int64_t fy = 0; arg_int(r->args, "fiscal_year", &fy); const char *until = arg_str(r->args, "until"); if (fy <= 0 || !util_parse_iso_date(until)) return fail(r, "INVALID_ARGS", "fiscal_year and until (YYYY-MM-DD) are required"); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE fiscal_years SET locked_until=?3 WHERE org_id=?1 AND id=?2" " AND ?3 BETWEEN start_date AND end_date", -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, until, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "fiscal year not found or until is outside it"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "period.lock", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year", fy); yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", until); return o; } static yyjson_mut_val *h_period_unlock(struct req *r) { int64_t fy = 0; arg_int(r->args, "fiscal_year", &fy); if (fy <= 0) return fail(r, "INVALID_ARGS", "fiscal_year is required"); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE fiscal_years SET locked_until=NULL 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, fy); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "fiscal year not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "period.unlock", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } static const struct cmd_arg args_fiscal_year_get[] = { { "id", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, }; static const struct cmd_arg args_fiscal_year_open[] = { { "label", ARG_STR, 1, NULL, NULL, "Fiscal year label" }, { "start_date", ARG_DATE, 1, NULL, NULL, "Start date (YYYY-MM-DD)" }, { "end_date", ARG_DATE, 1, NULL, NULL, "End date (YYYY-MM-DD)" }, }; static const struct cmd_arg args_fiscal_year_close[] = { { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "confirm", ARG_BOOL, 1, NULL, NULL, "Must be true" }, }; static const struct cmd_arg args_fiscal_year_reopen[] = { { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "confirm", ARG_BOOL, 1, NULL, NULL, "Must be true" }, }; static const struct cmd_arg args_fiscal_year_update[] = { { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "dividend_ore", ARG_INT, 0, NULL, NULL, "Proposed dividend in öre" }, { "events", ARG_STR, 0, NULL, NULL, "Material events during the year" }, { "agm_date", ARG_STR, 0, NULL, NULL, "AGM date; empty clears" }, { "dividend_date", ARG_STR, 0, NULL, NULL, "Dividend payment date; empty clears" }, { "employees", ARG_STR, 0, NULL, NULL, "Average number of employees" }, { "notes", ARG_STR, 0, NULL, NULL, "Other notes" }, }; static const struct cmd_arg args_period_lock[] = { { "fiscal_year", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "until", ARG_DATE, 1, NULL, NULL, "Lock through this date, inclusive" }, { "reason", ARG_STR, 0, NULL, NULL, "Reason, recorded in the audit log" }, }; static const struct cmd_arg args_period_unlock[] = { { "fiscal_year", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "reason", ARG_STR, 0, NULL, NULL, "Reason, recorded in the audit log" }, }; const struct command g_cmd_fiscal[] = { { "fiscal_year.list", "List fiscal years", PERM_READ, 1, 0, 0, h_fiscal_year_list, NULL, 0 }, { "fiscal_year.get", "Get a fiscal year", PERM_READ, 1, 0, 0, h_fiscal_year_get, CMD_ARGS(args_fiscal_year_get) }, { "fiscal_year.open", "Open a new fiscal year", PERM_OWNER, 1, 1, 1, h_fiscal_year_open, CMD_ARGS(args_fiscal_year_open) }, { "fiscal_year.close", "Close a fiscal year", PERM_OWNER, 1, 1, 0, h_fiscal_year_close, CMD_ARGS(args_fiscal_year_close) }, { "fiscal_year.reopen", "Reopen a closed fiscal year", PERM_OWNER, 1, 1, 1, h_fiscal_year_reopen, CMD_ARGS(args_fiscal_year_reopen) }, { "fiscal_year.update", "Update fiscal-year metadata (dividend)", PERM_WRITE, 1, 1, 1, h_fiscal_year_update, CMD_ARGS(args_fiscal_year_update) }, { "period.lock", "Lock a period through a date", PERM_OWNER, 1, 1, 1, h_period_lock, CMD_ARGS(args_period_lock) }, { "period.unlock", "Remove a period lock", PERM_OWNER, 1, 1, 1, h_period_unlock, CMD_ARGS(args_period_unlock) }, }; const struct cmd_table g_cmd_table_fiscal = { g_cmd_fiscal, sizeof g_cmd_fiscal / sizeof g_cmd_fiscal[0] };