diff options
Diffstat (limited to 'src/cmd_fiscal.c')
| -rw-r--r-- | src/cmd_fiscal.c | 484 |
1 files changed, 484 insertions, 0 deletions
diff --git a/src/cmd_fiscal.c b/src/cmd_fiscal.c new file mode 100644 index 0000000..94d2429 --- /dev/null +++ b/src/cmd_fiscal.c @@ -0,0 +1,484 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#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] +}; |
