diff options
47 files changed, 3355 insertions, 409 deletions
@@ -58,7 +58,8 @@ TUI_SCREEN_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(TUI_SCREENS)) TUI_SCREEN_DEP = $(TUI_SCREEN_OBJ:.o=.d) $(BUILD)/bokftui: $(BUILD)/clients/bokftui.o $(BUILD)/clients/ui.o \ - $(TUI_SCREEN_OBJ) $(BUILD)/clients/tui.o \ + $(BUILD)/clients/drafts.o $(TUI_SCREEN_OBJ) \ + $(BUILD)/clients/tui.o \ $(BUILD)/clients/client.o \ $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/src/formula.o \ $(BUILD)/vendor/yyjson.o $(BUILD)/vendor/sha256.o @@ -69,6 +70,7 @@ $(BUILD)/test_core: $(BUILD)/tests/test_core.o $(BUILD)/clients/client.o \ $(CC) $(CFLAGS) -o $@ $^ -lm $(SSL_LIBS) $(BUILD)/test_tui: $(BUILD)/tests/test_tui.o $(BUILD)/clients/tui.o \ + $(BUILD)/clients/drafts.o \ $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/vendor/yyjson.o \ $(BUILD)/vendor/sha256.o $(CC) $(CFLAGS) -o $@ $^ -lm -lncursesw @@ -156,6 +158,7 @@ $(BUILD)/tests/%.o: tests/%.c $(BUILD)/clients/bokfctl.d $(BUILD)/clients/bokftui.d \ $(BUILD)/clients/ui.d $(TUI_SCREEN_DEP) \ $(BUILD)/clients/tui.d $(BUILD)/clients/client.d \ + $(BUILD)/clients/drafts.d \ $(BUILD)/tests/test_core.d $(BUILD)/tests/test_tui.d \ $(BUILD)/tests/pdf_check.d $(BUILD)/tests/invoice_check.d \ $(BUILD)/tests/smtp_check.d \ diff --git a/clients/drafts.c b/clients/drafts.c new file mode 100644 index 0000000..f71e85c --- /dev/null +++ b/clients/drafts.c @@ -0,0 +1,249 @@ +#include "drafts.h" + +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <time.h> +#include <unistd.h> + +#include "util.h" +#include "yyjson.h" + +static void mkdirs_for(const char *path) +{ + char tmp[640]; + + if (snprintf(tmp, sizeof tmp, "%s", path) >= (int)sizeof tmp) + return; + for (char *p = tmp + 1; *p; p++) { + if (*p != '/') + continue; + *p = '\0'; + mkdir(tmp, 0700); + *p = '/'; + } +} + +static void cache_path(char *buf, size_t n) +{ + const char *cache = getenv("XDG_CACHE_HOME"); + + if (cache && *cache) { + snprintf(buf, n, "%s/bokf/drafts.json", cache); + return; + } + const char *home = getenv("HOME"); + snprintf(buf, n, "%s/.cache/bokf/drafts.json", + home && *home ? home : "."); +} + +static void rec_clear(struct draft *r) +{ + free(r->entity); + free(r->id); + free(r->fields); +} + +void drafts_free(struct drafts *d) +{ + if (!d) + return; + for (size_t i = 0; i < d->n; i++) + rec_clear(&d->v[i]); + free(d->v); + d->v = NULL; + d->n = d->cap = 0; +} + +static struct draft *rec_find(const struct drafts *d, int64_t org, + const char *entity, const char *id) +{ + for (size_t i = 0; i < d->n; i++) { + struct draft *r = &d->v[i]; + + if (r->org == org && strcmp(r->entity, entity) == 0 && + strcmp(r->id, id) == 0) + return r; + } + return NULL; +} + +static void rec_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields) +{ + struct draft *r = rec_find(d, org, entity, id); + + if (!r) { + if (d->n == d->cap) { + d->cap = d->cap ? d->cap * 2 : 8; + d->v = xrealloc(d->v, d->cap * sizeof *d->v); + } + r = &d->v[d->n++]; + memset(r, 0, sizeof *r); + r->org = org; + r->entity = xstrdup(entity); + r->id = xstrdup(id); + r->fields = xstrdup(fields && *fields ? fields : "{}"); + return; + } + free(r->fields); + r->fields = xstrdup(fields && *fields ? fields : "{}"); +} + +static void drafts_write(const struct drafts *d) +{ + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_int(doc, root, "version", 1); + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (size_t i = 0; i < d->n; i++) { + const struct draft *r = &d->v[i]; + yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr); + yyjson_mut_obj_add_int(doc, o, "org", r->org); + yyjson_mut_obj_add_strcpy(doc, o, "entity", r->entity); + yyjson_mut_obj_add_strcpy(doc, o, "id", r->id); + yyjson_mut_val *f = yyjson_mut_rawcpy(doc, r->fields); + yyjson_mut_obj_add_val(doc, o, "fields", f ? f : yyjson_mut_obj(doc)); + } + yyjson_mut_obj_add_val(doc, root, "drafts", arr); + char *json = yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, NULL); + yyjson_mut_doc_free(doc); + if (!json) + return; + + mkdirs_for(d->path); + char tmp[640]; + if (snprintf(tmp, sizeof tmp, "%s.tmp", d->path) >= (int)sizeof tmp) { + free(json); + return; + } + int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) { + FILE *f = fdopen(fd, "w"); + if (f) { + fputs(json, f); + fclose(f); + rename(tmp, d->path); + } else { + close(fd); + } + } + free(json); +} + +static void drafts_load(struct drafts *d) +{ + FILE *f = fopen(d->path, "rb"); + if (!f) + return; + struct buf b; + char tmp[8192]; + size_t k; + buf_init(&b); + while ((k = fread(tmp, 1, sizeof tmp, f)) > 0) + buf_append(&b, tmp, k); + fclose(f); + yyjson_doc *doc = yyjson_read((const char *)b.p, b.len, 0); + buf_free(&b); + if (!doc) + return; + yyjson_val *arr = yyjson_obj_get(yyjson_doc_get_root(doc), "drafts"); + if (yyjson_is_arr(arr)) { + size_t idx, max; + yyjson_val *it; + yyjson_arr_foreach(arr, idx, max, it) { + yyjson_val *org = yyjson_obj_get(it, "org"); + yyjson_val *entity = yyjson_obj_get(it, "entity"); + yyjson_val *id = yyjson_obj_get(it, "id"); + yyjson_val *fields = yyjson_obj_get(it, "fields"); + if (!yyjson_is_int(org) || !yyjson_is_str(entity) || + !yyjson_is_str(id) || !yyjson_is_obj(fields)) + continue; + char *ft = yyjson_val_write(fields, 0, NULL); + rec_put(d, yyjson_get_sint(org), yyjson_get_str(entity), + yyjson_get_str(id), ft ? ft : "{}"); + free(ft); + } + } + yyjson_doc_free(doc); +} + +void drafts_init_path(struct drafts *d, const char *path) +{ + memset(d, 0, sizeof *d); + snprintf(d->path, sizeof d->path, "%s", path); + drafts_load(d); +} + +void drafts_init(struct drafts *d) +{ + char path[600]; + cache_path(path, sizeof path); + drafts_init_path(d, path); +} + +const char *drafts_get(const struct drafts *d, int64_t org, + const char *entity, const char *id) +{ + const struct draft *r = rec_find(d, org, entity, id); + return r ? r->fields : NULL; +} + +int drafts_have(const struct drafts *d, int64_t org, const char *entity, + const char *id) +{ + return rec_find(d, org, entity, id) != NULL; +} + +void drafts_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields) +{ + rec_put(d, org, entity, id, fields); + drafts_write(d); +} + +void drafts_del(struct drafts *d, int64_t org, const char *entity, + const char *id) +{ + struct draft *r = rec_find(d, org, entity, id); + + if (!r) + return; + size_t i = (size_t)(r - d->v); + rec_clear(r); + memmove(&d->v[i], &d->v[i + 1], (d->n - i - 1) * sizeof *d->v); + d->n--; + drafts_write(d); +} + +size_t drafts_count(const struct drafts *d, int64_t org, const char *entity) +{ + size_t n = 0; + + for (size_t i = 0; i < d->n; i++) + if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0) + n++; + return n; +} + +void drafts_foreach(const struct drafts *d, int64_t org, const char *entity, + void (*cb)(void *ud, const char *id, const char *fields), + void *ud) +{ + for (size_t i = 0; i < d->n; i++) + if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0) + cb(ud, d->v[i].id, d->v[i].fields); +} + +char *drafts_new_id(void) +{ + static unsigned seq; + char buf[64]; + + snprintf(buf, sizeof buf, "ny-%lld-%u", (long long)time(NULL), ++seq); + return xstrdup(buf); +} diff --git a/clients/drafts.h b/clients/drafts.h new file mode 100644 index 0000000..2cc67db --- /dev/null +++ b/clients/drafts.h @@ -0,0 +1,49 @@ +#ifndef BOKF_DRAFTS_H +#define BOKF_DRAFTS_H + +#include <stddef.h> +#include <stdint.h> + +/* Client-local edit drafts: every non-committed edit is mirrored to + $XDG_CACHE_HOME/bokf/drafts.json (mode 0600, atomic replace) so a reload, + a crash or Ctrl+C never loses work. A record is keyed by + (org, entity, id); a new entity uses a temporary id from + drafts_new_id(). See docs/TUI-GUIDELINES.md "Interaction model". */ + +struct draft { + int64_t org; + char *entity; + char *id; + char *fields; /* JSON object text */ +}; + +struct drafts { + struct draft *v; + size_t n, cap; + char path[600]; +}; + +void drafts_init(struct drafts *d); +/* Explicit path, for tests. */ +void drafts_init_path(struct drafts *d, const char *path); +void drafts_free(struct drafts *d); + +/* The draft's fields JSON, or NULL; the store owns the string. */ +const char *drafts_get(const struct drafts *d, int64_t org, + const char *entity, const char *id); +int drafts_have(const struct drafts *d, int64_t org, const char *entity, + const char *id); +/* Inserts or replaces the draft and persists the file. */ +void drafts_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields); +void drafts_del(struct drafts *d, int64_t org, const char *entity, + const char *id); +size_t drafts_count(const struct drafts *d, int64_t org, const char *entity); +void drafts_foreach(const struct drafts *d, int64_t org, const char *entity, + void (*cb)(void *ud, const char *id, const char *fields), + void *ud); + +/* "ny-<time>-<seq>", unique within the process. Caller frees. */ +char *drafts_new_id(void); + +#endif diff --git a/clients/screens_attachments.c b/clients/screens_attachments.c index 3eeb9e9..c72ee67 100644 --- a/clients/screens_attachments.c +++ b/clients/screens_attachments.c @@ -158,6 +158,6 @@ void inbox_screen(struct app *a) continue; } if (sel_id) - attachment_download(a, sel_id); + attachment_view_or_download(a, sel_id); } } diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c index 3f69982..96c6fe9 100644 --- a/clients/screens_bokslut.c +++ b/clients/screens_bokslut.c @@ -242,7 +242,7 @@ void bokslut_screen(struct app *a) ff[7].cap = sizeof rate; ff[7].kind = TUI_F_TEXT; tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför F5 = visa" - " bokslutsplan ^Enter/F9 = bokför planen (frågar" + " bokslutsplan F9 = bokför planen (frågar" " först) Esc/q = tillbaka ^C = avsluta"); int r = tui_form_run_actions("Bokslut", ff, nf + 2, can_edit, acts, 5, NULL, &sel); diff --git a/clients/screens_ib.c b/clients/screens_ib.c index 8950667..2d62297 100644 --- a/clients/screens_ib.c +++ b/clients/screens_ib.c @@ -167,7 +167,7 @@ static int ib_post_delta(struct ibform *ib) yyjson_mut_doc_set_root(d, o); yyjson_mut_obj_add_strcpy(d, o, "date", a->fy_start); yyjson_mut_obj_add_strcpy(d, o, "description", "Ingående balans"); - yyjson_mut_obj_add_strcpy(d, o, "series", "IB"); + yyjson_mut_obj_add_strcpy(d, o, "series", a->ib_series); char *ref = util_random_id("tui-", 8); yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref); free(ref); @@ -272,7 +272,7 @@ static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold) for (;;) { int rr = tui_rt_run("Ingående balans", &ib.rt, "Tab = byta fält F5 = validera ^X = rensa rad" - " ^Enter/F9 = spara Esc = avbryt"); + " F9 = spara Esc = avbryt"); if (rr == -1) return 0; char msg[256]; diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c index ca59eef..712a8a8 100644 --- a/clients/screens_invoices.c +++ b/clients/screens_invoices.c @@ -14,6 +14,7 @@ #include <unistd.h> #include "client.h" +#include "drafts.h" #include "tui.h" #include "formula.h" #include "util.h" @@ -219,12 +220,128 @@ static int customer_save(struct app *a, int64_t id, const char *name, return ok; } -static void customer_form(struct app *a, int64_t id) +/* Drafts for the customer register: new entities exist only as drafts + until Spara writes them (docs/TUI-GUIDELINES.md "Interaction model"). */ + +static struct drafts g_drafts; +static int g_drafts_ready; + +static struct drafts *kdrafts(void) +{ + if (!g_drafts_ready) { + drafts_init(&g_drafts); + g_drafts_ready = 1; + } + return &g_drafts; +} + +struct cform_ctx { + struct app *a; + int64_t id; + const char *draft_id; + int deleted; + char name[256]; + char address[512]; + char postal[32]; + char city[128]; + char vat[64]; + char email[256]; + char your_ref[128]; + char payment[24]; + char notes[2048]; +}; + +static char *customer_fields_json(const struct cform_ctx *c) +{ + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_strcpy(d, o, "name", c->name); + yyjson_mut_obj_add_strcpy(d, o, "address", c->address); + yyjson_mut_obj_add_strcpy(d, o, "postal_code", c->postal); + yyjson_mut_obj_add_strcpy(d, o, "city", c->city); + yyjson_mut_obj_add_strcpy(d, o, "vat_nr", c->vat); + yyjson_mut_obj_add_strcpy(d, o, "email", c->email); + yyjson_mut_obj_add_strcpy(d, o, "your_ref", c->your_ref); + yyjson_mut_obj_add_int(d, o, "payment_days", + strtoll(c->payment, NULL, 10)); + yyjson_mut_obj_add_strcpy(d, o, "notes", c->notes); + char *s = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + return s; +} + +static void customer_load_json(const char *json, struct cform_ctx *c) +{ + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + + if (!doc) + return; + yyjson_val *o = yyjson_doc_get_root(doc); + const struct { + const char *key; + char *dst; + size_t cap; + } fields[] = { + { "name", c->name, sizeof c->name }, + { "address", c->address, sizeof c->address }, + { "postal_code", c->postal, sizeof c->postal }, + { "city", c->city, sizeof c->city }, + { "vat_nr", c->vat, sizeof c->vat }, + { "email", c->email, sizeof c->email }, + { "your_ref", c->your_ref, sizeof c->your_ref }, + { "notes", c->notes, sizeof c->notes }, + }; + for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) + snprintf(fields[i].dst, fields[i].cap, "%s", + vstr(o, fields[i].key)); + int64_t days = vint(o, "payment_days"); + if (days > 0) + snprintf(c->payment, sizeof c->payment, "%lld", (long long)days); + yyjson_doc_free(doc); +} + +static int cform_key(void *ud, int ch) +{ + struct cform_ctx *c = ud; + + if (ch != KEY_F(2)) + return TUI_HOOK_NONE; + int have = drafts_have(kdrafts(), c->a->org, "customer", c->draft_id); + struct tui_action acts[] = { + { "customer.save", "Spara", 0, 1, NULL }, + { "customer.draft.delete", "Radera utkast", 0, have, "inget utkast" }, + }; + static int afocus; + int r = tui_action_menu("Åtgärder", acts, 2, &afocus); + if (r == 0) + return TUI_HOOK_SUBMIT; + if (r == 1) { + if (tui_confirm("Kund", "Radera utkastet?")) { + drafts_del(kdrafts(), c->a->org, "customer", c->draft_id); + c->deleted = 1; + return TUI_HOOK_BACK; + } + return TUI_HOOK_STAY; + } + return TUI_HOOK_STAY; +} + +static void customer_form(struct app *a, int64_t id, const char *draft_id) { - char name[256] = "", address[512] = "", postal[32] = "", city[128] = ""; - char vat[64] = "", email[256] = "", your_ref[128] = ""; - char payment[16] = "30", notes[2048] = ""; - if (id > 0) { + struct cform_ctx c; + + memset(&c, 0, sizeof c); + c.a = a; + c.id = id; + c.draft_id = draft_id; + snprintf(c.payment, sizeof c.payment, "30"); + + const char *fields = drafts_get(kdrafts(), a->org, "customer", draft_id); + if (fields) { + customer_load_json(fields, &c); + } else if (id > 0) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id); char *resp = @@ -234,43 +351,46 @@ static void customer_form(struct app *a, int64_t id) free(resp); return; } - struct { + const struct { const char *key; char *dst; size_t cap; - } fields[] = { - { "name", name, sizeof name }, - { "address", address, sizeof address }, - { "postal_code", postal, sizeof postal }, - { "city", city, sizeof city }, - { "vat_nr", vat, sizeof vat }, - { "email", email, sizeof email }, - { "your_ref", your_ref, sizeof your_ref }, - { "notes", notes, sizeof notes }, + } f[] = { + { "name", c.name, sizeof c.name }, + { "address", c.address, sizeof c.address }, + { "postal_code", c.postal, sizeof c.postal }, + { "city", c.city, sizeof c.city }, + { "vat_nr", c.vat, sizeof c.vat }, + { "email", c.email, sizeof c.email }, + { "your_ref", c.your_ref, sizeof c.your_ref }, + { "notes", c.notes, sizeof c.notes }, }; - for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) { - char path[64], *v; - snprintf(path, sizeof path, "result.%s", fields[i].key); + for (size_t i = 0; i < sizeof f / sizeof f[0]; i++) { + char path[64]; + char *v; + snprintf(path, sizeof path, "result.%s", f[i].key); v = jstr_dup(resp, path); if (v) { - snprintf(fields[i].dst, fields[i].cap, "%s", v); + snprintf(f[i].dst, f[i].cap, "%s", v); free(v); } } - snprintf(payment, sizeof payment, "%lld", + snprintf(c.payment, sizeof c.payment, "%lld", (long long)jint_val(resp, "result.payment_days", 30)); free(resp); } + const char *labels[] = { "Namn", "Adress", "Postnummer", "Ort", "Momsreg.nr", "E-post", "Er referens", "Betalningsdagar", "Anteckningar" }; - char *vals[] = { name, address, postal, city, vat, - email, your_ref, payment, notes }; - size_t caps[] = { sizeof name, sizeof address, sizeof postal, - sizeof city, sizeof vat, sizeof email, - sizeof your_ref, sizeof payment, sizeof notes }; + char *vals[] = { c.name, c.address, c.postal, c.city, + c.vat, c.email, c.your_ref, c.payment, + c.notes }; + size_t caps[] = { sizeof c.name, sizeof c.address, sizeof c.postal, + sizeof c.city, sizeof c.vat, sizeof c.email, + sizeof c.your_ref, sizeof c.payment, sizeof c.notes }; struct tui_form_field ff[10]; memset(ff, 0, sizeof ff); for (int i = 0; i < 9; i++) { @@ -282,22 +402,47 @@ static void customer_form(struct app *a, int64_t id) ff[9].label = ""; ff[9].value = "Spara"; ff[9].kind = TUI_F_ACTION; + static int sel = 0; + tui_form_hint_extra("F2 = åtgärder"); for (;;) { - int r = tui_form_run(id > 0 ? "Kund" : "Ny kund", ff, 10, 1, &sel); + char title[64]; + snprintf(title, sizeof title, "%s%s", id > 0 ? "Kund" : "Ny kund", + drafts_have(kdrafts(), a->org, "customer", draft_id) + ? " <UTKAST>" + : ""); + int r = tui_form_run_hook(title, ff, 10, 1, cform_key, &c, &sel); + if (c.deleted) + break; + if (r >= 0) { + char *json = customer_fields_json(&c); + if (json) { + drafts_put(kdrafts(), a->org, "customer", draft_id, json); + free(json); + } + } if (r == TUI_FORM_BACK) - return; + break; if (r == TUI_FORM_REFRESH) { - customer_save(a, id, name, address, postal, city, vat, email, - your_ref, payment, notes, 1); + customer_save(a, id, c.name, c.address, c.postal, c.city, c.vat, + c.email, c.your_ref, c.payment, c.notes, 1); continue; } if (r == TUI_FORM_SUBMIT || r == 9) { - if (customer_save(a, id, name, address, postal, city, vat, email, - your_ref, payment, notes, 0)) - return; + if (customer_save(a, id, c.name, c.address, c.postal, c.city, + c.vat, c.email, c.your_ref, c.payment, c.notes, + 0)) { + drafts_del(kdrafts(), a->org, "customer", draft_id); + break; + } + char *json = customer_fields_json(&c); + if (json) { + drafts_put(kdrafts(), a->org, "customer", draft_id, json); + free(json); + } } } + tui_form_hint_extra(NULL); } static void customer_archive_toggle(struct app *a, int64_t id, const char *name, @@ -319,59 +464,170 @@ static void customer_archive_toggle(struct app *a, int64_t id, const char *name, free(r); } +struct clist { + struct app *a; + int *cur; + char **items; + char **names; + char **dkeys; + int64_t *ids; + unsigned char *active; + size_t nserver; + size_t nrows; /* server rows + temp drafts; the add row is at nrows */ +}; + +static void clist_clear(struct clist *l) +{ + if (l->items) { + for (size_t i = 0; i <= l->nrows; i++) { + free(l->items[i]); + free(l->names[i]); + free(l->dkeys[i]); + } + } + free(l->items); + free(l->names); + free(l->dkeys); + free(l->ids); + free(l->active); + l->items = NULL; + l->names = NULL; + l->dkeys = NULL; + l->ids = NULL; + l->active = NULL; + l->nserver = l->nrows = 0; +} + +static int clist_is_server_id(const struct clist *l, const char *id) +{ + long long v = strtoll(id, NULL, 10); + + if (v <= 0) + return 0; + for (size_t i = 0; i < l->nserver; i++) + if (l->ids[i] == v) + return 1; + return 0; +} + +static void clist_collect_temp(void *ud, const char *id, const char *fields) +{ + struct clist *l = ud; + char nbuf[256], line[768]; + const char *name = "(namnlös)"; + yyjson_doc *doc; + + if (clist_is_server_id(l, id)) + return; + doc = yyjson_read(fields, strlen(fields), 0); + if (doc) { + yyjson_val *o = yyjson_doc_get_root(doc); + const char *n = vstr(o, "name"); + if (*n) + name = n; + size_t i = l->nrows; + l->names[i] = xstrdup(name); + snprintf(nbuf, sizeof nbuf, "<UTKAST> %s", name); + snprintf(line, sizeof line, "%s", nbuf); + l->items[i] = xstrdup(line); + l->dkeys[i] = xstrdup(id); + l->ids[i] = 0; + l->active[i] = 1; + l->nrows++; + yyjson_doc_free(doc); + return; + } + size_t i = l->nrows; + l->names[i] = xstrdup(name); + snprintf(line, sizeof line, "<UTKAST> %s", name); + l->items[i] = xstrdup(line); + l->dkeys[i] = xstrdup(id); + l->ids[i] = 0; + l->active[i] = 1; + l->nrows++; +} + +static int clist_key(void *ud, int ch) +{ + struct clist *l = ud; + static int afocus; + + if (ch != KEY_F(2) || !l->cur) + return TUI_HOOK_NONE; + int i = *l->cur; + if (i < 0 || (size_t)i >= l->nrows) + return TUI_HOOK_STAY; + struct tui_action acts[] = { + { "customer.open", "Öppna", 0, 1, NULL }, + { "customer.draft.delete", "Radera utkast", 0, l->dkeys[i] != NULL, + "inget utkast" }, + { "customer.archive", l->active[i] ? "Arkivera" : "Återaktivera", 0, + l->ids[i] != 0, "gäller inte utkast" }, + }; + int r = tui_action_menu("Åtgärder", acts, 3, &afocus); + if (r == 0) { + char idbuf[32]; + const char *dk = l->dkeys[i]; + if (!dk) { + snprintf(idbuf, sizeof idbuf, "%lld", (long long)l->ids[i]); + dk = idbuf; + } + customer_form(l->a, l->ids[i], dk); + return TUI_HOOK_REFRESH; + } + if (r == 1) { + if (tui_confirm("Kund", "Radera utkastet?")) + drafts_del(kdrafts(), l->a->org, "customer", l->dkeys[i]); + return TUI_HOOK_REFRESH; + } + if (r == 2) { + customer_archive_toggle(l->a, l->ids[i], l->names[i], + l->active[i] != 0); + return TUI_HOOK_REFRESH; + } + return TUI_HOOK_STAY; +} + void customers_screen(struct app *a) { - char **items = NULL; - char **names = NULL; - int64_t *ids = NULL; - unsigned char *active = NULL; - size_t n = 0; + struct clist l; + memset(&l, 0, sizeof l); + l.a = a; + int cur = 0; + l.cur = &cur; int fetch = 1; for (;;) { if (g_quit) { - for (size_t i = 0; i < n; i++) { - free(items[i]); - free(names[i]); - } - free(items); - free(names); - free(ids); - free(active); + clist_clear(&l); return; } if (fetch) { - for (size_t i = 0; i < n; i++) { - free(items[i]); - free(names[i]); - } - free(items); - free(names); - free(ids); - free(active); - items = NULL; - names = NULL; - ids = NULL; - active = NULL; - n = 0; + clist_clear(&l); fetch = 0; char *resp = client_rpc(&a->conn, "customer.list", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Kunder", resp); free(resp); + clist_clear(&l); return; } - n = jarr_size(resp, "result.items"); - items = xcalloc(n + 1, sizeof(char *)); - names = xcalloc(n + 1, sizeof(char *)); - ids = xcalloc(n ? n : 1, sizeof(int64_t)); - active = xcalloc(n + 1, 1); + size_t n = jarr_size(resp, "result.items"); + size_t ndraft = drafts_count(kdrafts(), a->org, "customer"); + size_t cap = n + ndraft + 2; + l.items = xcalloc(cap, sizeof(char *)); + l.names = xcalloc(cap, sizeof(char *)); + l.dkeys = xcalloc(cap, sizeof(char *)); + l.ids = xcalloc(cap, sizeof(int64_t)); + l.active = xcalloc(cap, 1); + l.nserver = n; + l.nrows = n; for (size_t i = 0; i < n; i++) { - char path[64], line[768]; + char path[64], line[768], idbuf[32]; snprintf(path, sizeof path, "result.items.%zu.id", i); - ids[i] = jint_val(resp, path, 0); + l.ids[i] = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.active", i); - active[i] = (unsigned char)jbool_val(resp, path, 1); + l.active[i] = (unsigned char)jbool_val(resp, path, 1); snprintf(path, sizeof path, "result.items.%zu.name", i); char *name = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.postal_code", i); @@ -390,60 +646,81 @@ void customers_screen(struct app *a) tui_pad_field(pbuf, sizeof pbuf, 30); snprintf(vbuf, sizeof vbuf, "%s", vat ? vat : ""); tui_pad_field(vbuf, sizeof vbuf, 18); - snprintf(line, sizeof line, "%s %s %s %3lld dagar %s", nbuf, - pbuf, vbuf, (long long)days, - active[i] ? "" : "(arkiverad)"); - items[i] = xstrdup(line); - names[i] = xstrdup(name ? name : ""); + snprintf(idbuf, sizeof idbuf, "%lld", (long long)l.ids[i]); + int isdraft = + drafts_have(kdrafts(), a->org, "customer", idbuf); + if (isdraft) + l.dkeys[i] = xstrdup(idbuf); + snprintf(line, sizeof line, "%s%s %s %s %3lld dagar %s", + isdraft ? "<UTKAST> " : "", nbuf, pbuf, vbuf, + (long long)days, + l.active[i] ? "" : "(arkiverad)"); + l.items[i] = xstrdup(line); + l.names[i] = xstrdup(name ? name : ""); free(name); free(postal); free(city); free(vat); } free(resp); - items[n] = xstrdup("+ Ny kund (^N)"); + drafts_foreach(kdrafts(), a->org, "customer", clist_collect_temp, + &l); + l.items[l.nrows] = xstrdup("+ Ny kund (^N)"); + l.names[l.nrows] = xstrdup(""); + l.ids[l.nrows] = 0; + l.active[l.nrows] = 1; } int start = 0; if (a->customer_sel) { - for (size_t i = 0; i < n; i++) - if (ids[i] == a->customer_sel) { + for (size_t i = 0; i < l.nserver; i++) + if (l.ids[i] == a->customer_sel) { start = (int)i; break; } } - int cur = start; - int sel = tui_select_list("Kunder", items, (int)n + 1, start, 1, &cur, - 1, "arkivera/återaktivera", 0); - if (cur >= 0 && (size_t)cur < n) - a->customer_sel = ids[cur]; + cur = start; + tui_list_hint_extra("F2 = åtgärder"); + int sel = tui_select_list_hook("Kunder", l.items, + (int)l.nrows + 1, start, 1, &cur, 1, + "arkivera/återaktivera", 0, clist_key, + &l); + tui_list_hint_extra(NULL); + if (cur >= 0 && (size_t)cur < l.nserver) + a->customer_sel = l.ids[cur]; if (sel == -1) { - for (size_t i = 0; i < n; i++) { - free(items[i]); - free(names[i]); - } - free(items); - free(names); - free(ids); - free(active); + clist_clear(&l); return; } if (sel == -2) { fetch = 1; continue; } - if (sel == -6 && cur >= 0 && (size_t)cur < n) { - customer_archive_toggle(a, ids[cur], names[cur], - active[cur] != 0); + if (sel == -6 && cur >= 0 && (size_t)cur < l.nrows) { + if (l.ids[cur] == 0) { + tui_message("Kund", "Utkast raderas med F2."); + } else { + customer_archive_toggle(a, l.ids[cur], l.names[cur], + l.active[cur] != 0); + } fetch = 1; continue; } - if (sel == -4 || (sel >= 0 && (size_t)sel == n)) { - customer_form(a, 0); + if (sel == -4 || (cur >= 0 && (size_t)cur == l.nrows)) { + char *tid = drafts_new_id(); + drafts_put(kdrafts(), a->org, "customer", tid, "{}"); + customer_form(a, 0, tid); + free(tid); fetch = 1; continue; } - if (sel >= 0 && (size_t)sel < n) { - customer_form(a, ids[sel]); + if (sel >= 0 && (size_t)sel < l.nrows) { + char idbuf[32]; + const char *dk = l.dkeys[sel]; + if (!dk) { + snprintf(idbuf, sizeof idbuf, "%lld", (long long)l.ids[sel]); + dk = idbuf; + } + customer_form(a, l.ids[sel], dk); fetch = 1; continue; } @@ -457,9 +734,13 @@ struct invctx { int64_t id; int64_t number; int64_t customer_id; + int64_t total_ore; char customer[256]; + char paid_date[16]; }; +static int64_t invoices_new_from(struct app *a, int64_t id); + static void inv_detail_pdf(struct invctx *ctx) { char args[64]; @@ -476,7 +757,7 @@ static void inv_detail_pdf(struct invctx *ctx) char fname[800]; snprintf(fname, sizeof fname, "Faktura %lld %s.pdf", (long long)ctx->number, ctx->customer); - pdf_save_and_open(b64, fname, "Faktura PDF"); + file_save_and_open(b64, fname, "Faktura PDF"); free(b64); } free(resp); @@ -523,6 +804,61 @@ static void inv_detail_send(struct invctx *ctx) free(r); } +/* Prefills the new-invoice form with the payment voucher: D the org's + bank account, K the invoice receivable account, both editable. */ +static void inv_detail_pay(struct invctx *ctx) +{ + struct app *a = ctx->a; + if (ctx->paid_date[0]) { + tui_message("Kvittera betalning", "Fakturan är redan betald."); + return; + } + char pay[16] = "1930", rec[16] = "1510"; + char *resp = client_rpc(&a->conn, "settings.get", a->session, a->org, + "{}"); + if (resp && client_ok(resp)) { + char *v = jstr_dup(resp, "result.bank_account"); + if (v && *v) + snprintf(pay, sizeof pay, "%s", v); + free(v); + v = jstr_dup(resp, "result.invoice_receivable_account"); + if (v && *v) + snprintf(rec, sizeof rec, "%s", v); + free(v); + } + free(resp); + char desc[160]; + snprintf(desc, sizeof desc, "Betalning faktura %lld %.120s", + (long long)ctx->number, ctx->customer); + struct voucher_prefill p; + memset(&p, 0, sizeof p); + char date[16]; + today_iso(date, sizeof date); + p.date = date; + p.description = desc; + p.bank_account = pay; + p.counter_account = rec; + p.amount_ore = ctx->total_ore; + int64_t vid = vouchers_new_prefill(a, &p); + if (vid <= 0) + return; + char args[96]; + snprintf(args, sizeof args, "{\"id\":%lld,\"voucher_id\":%lld}", + (long long)ctx->id, (long long)vid); + resp = client_rpc(&a->conn, "invoice.pay", a->session, a->org, args); + if (resp && client_ok(resp)) { + char *ser = jstr_dup(resp, "result.voucher_series"); + int64_t num = jint_val(resp, "result.voucher_number", 0); + tui_message("Kvittera betalning", "Faktura %lld kvitterad (%s%lld).", + (long long)ctx->number, ser ? ser : "", + (long long)num); + free(ser); + } else { + show_error("Kvittera betalning", resp); + } + free(resp); +} + static int inv_key(void *ud, int ch) { struct invctx *ctx = ud; @@ -534,6 +870,14 @@ static int inv_key(void *ud, int ch) inv_detail_send(ctx); return TUI_HOOK_STAY; } + if (ch == 'u') { + invoices_new_from(ctx->a, ctx->id); + return TUI_HOOK_REFRESH; + } + if (ch == 'b') { + inv_detail_pay(ctx); + return TUI_HOOK_REFRESH; + } return TUI_HOOK_NONE; } @@ -565,10 +909,19 @@ static void invoices_detail(struct app *a, int64_t id) char *ocr = jstr_dup(resp, "result.ocr"); char *sent_at = jstr_dup(resp, "result.last_sent_at"); char *sent_to = jstr_dup(resp, "result.last_sent_to"); + char *paid_date = jstr_dup(resp, "result.paid_date"); + char *paid_ser = jstr_dup(resp, "result.paid_voucher_series"); + int64_t paid_num = jint_val(resp, "result.paid_voucher_number", 0); + char statbuf[96]; + if (paid_date && *paid_date) + snprintf(statbuf, sizeof statbuf, "betald %s", paid_date); + else + snprintf(statbuf, sizeof statbuf, "%s", + invoice_status_sv(status)); struct buf t; buf_init(&t); buf_line(&t, MK_HEAD "Faktura %lld %s\n", (long long)number, - invoice_status_sv(status)); + statbuf); buf_line(&t, MK_DIM "%s\n\n", cust ? cust : ""); buf_line(&t, "Fakturadatum: %s\n", date ? date : ""); buf_line(&t, "Förfallodatum: %s\n", due ? due : ""); @@ -584,6 +937,9 @@ static void invoices_detail(struct app *a, int64_t id) buf_line(&t, "Skickad: %s%s%s\n", sent_at, sent_to && *sent_to ? " till " : "", sent_to && *sent_to ? sent_to : ""); + if (paid_ser && *paid_ser && paid_num > 0) + buf_line(&t, "Betalningsverifikat: %s%lld\n", paid_ser, + (long long)paid_num); if (notes && *notes) buf_line(&t, "\nFritext:\n%s\n", notes); buf_line(&t, "\n"); @@ -609,6 +965,18 @@ static void invoices_detail(struct app *a, int64_t id) char *note = jstr_dup(resp, path); snprintf(path, sizeof path, "result.rows.%zu.amount_ore", i); int64_t amount = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.rows.%zu.is_text", i); + if (jbool_val(resp, path, 0)) { + char dbuf[256]; + snprintf(dbuf, sizeof dbuf, "%s", desc ? desc : ""); + tui_pad_field(dbuf, sizeof dbuf, 34); + buf_line(&t, "%s\n", dbuf); + free(desc); + free(unit); + free(vat_code); + free(note); + continue; + } char dbuf[256], qbuf[24], qcol[24], ubuf[24], pcol[40], vbuf[24], nbuf[128], acol[40]; snprintf(dbuf, sizeof dbuf, "%s", desc ? desc : ""); @@ -644,10 +1012,16 @@ static void invoices_detail(struct app *a, int64_t id) ctx.id = id; ctx.number = number; ctx.customer_id = customer_id; + ctx.total_ore = total; snprintf(ctx.customer, sizeof ctx.customer, "%s", cust ? cust : ""); - int ret = tui_pager_hook("Faktura", (const char *)t.p, - "p = visa PDF s = skicka", 0, inv_key, - &ctx); + if (paid_date && *paid_date) + snprintf(ctx.paid_date, sizeof ctx.paid_date, "%s", paid_date); + char ahint[192]; + snprintf(ahint, sizeof ahint, + "p = visa PDF s = skicka u = duplicera%s", + ctx.paid_date[0] ? "" : " b = kvittera betalning"); + int ret = tui_pager_hook("Faktura", (const char *)t.p, ahint, 0, + inv_key, &ctx); free(cust); free(status); free(date); @@ -659,6 +1033,8 @@ static void invoices_detail(struct app *a, int64_t id) free(ocr); free(sent_at); free(sent_to); + free(paid_date); + free(paid_ser); buf_free(&t); free(resp); if (ret != 1) @@ -890,7 +1266,7 @@ static char *iform_draft_args(struct iform *f, char *err, size_t errn) yyjson_mut_obj_add_strcpy(d, o, "our_ref", f->our_ref); yyjson_mut_obj_add_strcpy(d, o, "notes", f->notes); yyjson_mut_val *arr = yyjson_mut_arr(d); - int used = 0; + int used = 0, priced = 0; for (int i = 0; i < f->rt.nrows; i++) { struct irow *r = &f->rows[i]; if (!r->description[0] && !r->quantity[0] && !r->unit[0] && @@ -901,6 +1277,13 @@ static char *iform_draft_args(struct iform *f, char *err, size_t errn) yyjson_mut_doc_free(d); return NULL; } + if (!r->quantity[0] && !r->unit[0] && !r->price[0] && !r->vat[0]) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr); + yyjson_mut_obj_add_strcpy(d, ro, "description", r->description); + yyjson_mut_obj_add_bool(d, ro, "text", true); + used++; + continue; + } const char *qty = r->quantity[0] ? r->quantity : "1"; if (!qty_is_valid(qty)) { snprintf(err, errn, @@ -937,12 +1320,19 @@ static char *iform_draft_args(struct iform *f, char *err, size_t errn) yyjson_mut_obj_add_strcpy(d, ro, "note", r->note); yyjson_mut_obj_add_strcpy(d, ro, "vat_code", vat); used++; + priced++; } if (used == 0) { snprintf(err, errn, "Minst en rad krävs."); yyjson_mut_doc_free(d); return NULL; } + if (priced == 0) { + snprintf(err, errn, + "Minst en prissatt rad krävs (fritextrader saknar belopp)."); + yyjson_mut_doc_free(d); + return NULL; + } yyjson_mut_obj_add_val(d, o, "rows", arr); char *out = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); @@ -967,7 +1357,7 @@ static void iform_preview(struct iform *f) } char *b64 = jstr_dup(resp, "result.content_base64"); if (b64) { - pdf_save_and_open(b64, "Faktura förhandsvisning.pdf", "Faktura PDF"); + file_save_and_open(b64, "Faktura förhandsvisning.pdf", "Faktura PDF"); free(b64); } free(resp); @@ -1021,33 +1411,8 @@ static int64_t iform_issue(struct iform *f) return id; } -static int64_t invoices_new(struct app *a) +static int64_t invoices_form_run(struct iform *f) { - struct iform f; - memset(&f, 0, sizeof f); - f.a = a; - today_iso(f.date, sizeof f.date); - snprintf(f.delivery, sizeof f.delivery, "%s", f.date); - snprintf(f.due, sizeof f.due, "%s", f.date); - snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due); - if (iform_load_customers(&f) != 0) { - iform_customers_free(&f); - return 0; - } - char *resp = - client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); - if (resp && client_ok(resp)) { - char *v = jstr_dup(resp, "result.invoice_our_ref"); - if (v) { - snprintf(f.our_ref, sizeof f.our_ref, "%s", v); - free(v); - } - } - free(resp); - if (f.ncust > 0) { - f.cust_sel = 0; - iform_customer_changed(&f); - } struct tui_rt_col cols[6] = { { "Beskrivning", 2, 34, TUI_RT_EDIT }, { "Antal", 37, 8, TUI_RT_EDIT }, @@ -1056,58 +1421,181 @@ static int64_t invoices_new(struct app *a) { "Moms", 68, 14, TUI_RT_EDIT }, { "Anm", 83, 32, TUI_RT_EDIT }, }; - tui_rt_init(&f.rt, 1, IFORM_ROWS, 6, cols, 0, iform_cell, NULL, &f); - tui_rt_set_key(&f.rt, iform_key); + tui_rt_init(&f->rt, 1, IFORM_ROWS, 6, cols, 0, iform_cell, NULL, f); + tui_rt_set_key(&f->rt, iform_key); struct tui_form_field ff[7]; memset(ff, 0, sizeof ff); ff[0].label = "Kund"; ff[0].kind = TUI_F_CHOICE; - ff[0].choices = (const char *const *)f.cust_names; - ff[0].nchoices = (int)f.ncust; - ff[0].choice = &f.cust_sel; + ff[0].choices = (const char *const *)f->cust_names; + ff[0].nchoices = (int)f->ncust; + ff[0].choice = &f->cust_sel; ff[1].label = "Fakturadatum"; - ff[1].value = f.date; - ff[1].cap = sizeof f.date; + ff[1].value = f->date; + ff[1].cap = sizeof f->date; ff[1].kind = TUI_F_DATE; ff[2].label = "Förfallodatum"; - ff[2].value = f.due; - ff[2].cap = sizeof f.due; + ff[2].value = f->due; + ff[2].cap = sizeof f->due; ff[2].kind = TUI_F_DATE; ff[3].label = "Leveransdatum"; - ff[3].value = f.delivery; - ff[3].cap = sizeof f.delivery; + ff[3].value = f->delivery; + ff[3].cap = sizeof f->delivery; ff[3].kind = TUI_F_DATE; ff[4].label = "Er referens"; - ff[4].value = f.your_ref; - ff[4].cap = sizeof f.your_ref; + ff[4].value = f->your_ref; + ff[4].cap = sizeof f->your_ref; ff[4].kind = TUI_F_TEXT; ff[5].label = "Vår referens"; - ff[5].value = f.our_ref; - ff[5].cap = sizeof f.our_ref; + ff[5].value = f->our_ref; + ff[5].cap = sizeof f->our_ref; ff[5].kind = TUI_F_TEXT; ff[6].label = "Fritext"; - ff[6].value = f.notes; - ff[6].cap = sizeof f.notes; + ff[6].value = f->notes; + ff[6].cap = sizeof f->notes; ff[6].kind = TUI_F_TEXT; - tui_rt_set_fields(&f.rt, ff, 7); + tui_rt_set_fields(&f->rt, ff, 7); + tui_rt_normalize(&f->rt); const char *hint = "Enter = välj/ändra Tab = byta fält F5 = förhandsvisa" - " ^Enter/F9 = utfärda Esc = avbryt"; + " F9 = utfärda Esc = avbryt"; int64_t out = 0; for (;;) { - int rr = tui_rt_run("Ny faktura", &f.rt, hint); + int rr = tui_rt_run("Ny faktura", &f->rt, hint); if (rr == -1) break; if (rr == -2) { - iform_preview(&f); + iform_preview(f); continue; } if (rr == -3) { - out = iform_issue(&f); + out = iform_issue(f); if (out > 0) break; } } + return out; +} + +static int64_t invoices_new(struct app *a) +{ + struct iform f; + memset(&f, 0, sizeof f); + f.a = a; + today_iso(f.date, sizeof f.date); + snprintf(f.delivery, sizeof f.delivery, "%s", f.date); + snprintf(f.due, sizeof f.due, "%s", f.date); + snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due); + if (iform_load_customers(&f) != 0) { + iform_customers_free(&f); + return 0; + } + char *resp = + client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); + if (resp && client_ok(resp)) { + char *v = jstr_dup(resp, "result.invoice_our_ref"); + if (v) { + snprintf(f.our_ref, sizeof f.our_ref, "%s", v); + free(v); + } + } + free(resp); + if (f.ncust > 0) { + f.cust_sel = 0; + iform_customer_changed(&f); + } + int64_t out = invoices_form_run(&f); + iform_customers_free(&f); + return out; +} + +/* Duplicates an issued invoice: same customer, rows and references, with + the dates reset (invoice and delivery today, due today + payment days). */ +static int64_t invoices_new_from(struct app *a, int64_t id) +{ + char iargs[64]; + snprintf(iargs, sizeof iargs, "{\"id\":%lld}", (long long)id); + char *resp = + client_rpc(&a->conn, "invoice.get", a->session, a->org, iargs); + if (!resp || !client_ok(resp)) { + show_error("Faktura", resp); + free(resp); + return 0; + } + struct iform f; + memset(&f, 0, sizeof f); + f.a = a; + today_iso(f.date, sizeof f.date); + snprintf(f.delivery, sizeof f.delivery, "%s", f.date); + snprintf(f.due, sizeof f.due, "%s", f.date); + snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due); + if (iform_load_customers(&f) != 0) { + iform_customers_free(&f); + free(resp); + return 0; + } + int64_t cid = jint_val(resp, "result.customer_id", 0); + int found = -1; + for (size_t i = 0; i < f.ncust; i++) + if (f.cust_ids[i] == cid) { + found = (int)i; + break; + } + if (found < 0) { + tui_message("Ny faktura", + "Kunden är arkiverad eller finns inte kvar."); + iform_customers_free(&f); + free(resp); + return 0; + } + f.cust_sel = found; + iform_customer_changed(&f); + char *v = jstr_dup(resp, "result.our_ref"); + if (v) { + snprintf(f.our_ref, sizeof f.our_ref, "%s", v); + free(v); + } + v = jstr_dup(resp, "result.notes"); + if (v) { + snprintf(f.notes, sizeof f.notes, "%s", v); + free(v); + } + size_t nrows = jarr_size(resp, "result.rows"); + if (nrows > IFORM_ROWS) + nrows = IFORM_ROWS; + for (size_t i = 0; i < nrows; i++) { + struct irow *r = &f.rows[i]; + char path[64]; + snprintf(path, sizeof path, "result.rows.%zu.description", i); + char *desc = jstr_dup(resp, path); + if (desc) + snprintf(r->description, sizeof r->description, "%s", desc); + snprintf(path, sizeof path, "result.rows.%zu.is_text", i); + if (jbool_val(resp, path, 0)) { + free(desc); + continue; + } + snprintf(path, sizeof path, "result.rows.%zu.quantity_milli", i); + int64_t qm = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.rows.%zu.unit", i); + char *unit = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.rows.%zu.unit_price_ore", i); + int64_t price = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.rows.%zu.vat_code", i); + char *vat = jstr_dup(resp, path); + qty_sv(qm, r->quantity, sizeof r->quantity); + if (unit) + snprintf(r->unit, sizeof r->unit, "%s", unit); + char pbuf[24]; + tui_kr_format(price, pbuf, sizeof pbuf); + snprintf(r->price, sizeof r->price, "%s", pbuf); + snprintf(r->vat, sizeof r->vat, "%s", vat_label(vat)); + free(desc); + free(unit); + free(vat); + } + free(resp); + int64_t out = invoices_form_run(&f); iform_customers_free(&f); return out; } @@ -1187,18 +1675,25 @@ void invoices_screen(struct app *a) int64_t total = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.status", i); char *status = jstr_dup(resp, path); - char nbuf[32], cbuf[256], abuf[40]; + snprintf(path, sizeof path, "result.items.%zu.paid_date", i); + char *paid = jstr_dup(resp, path); + char nbuf[32], cbuf[256], abuf[40], stbuf[64]; snprintf(nbuf, sizeof nbuf, "Faktura %lld", (long long)number); snprintf(cbuf, sizeof cbuf, "%s", cust ? cust : ""); tui_pad_field(cbuf, sizeof cbuf, 28); tui_amt_col(abuf, sizeof abuf, 14, total); + if (paid && *paid) + snprintf(stbuf, sizeof stbuf, "betald %s", paid); + else + snprintf(stbuf, sizeof stbuf, "%s", + invoice_status_sv(status)); snprintf(line, sizeof line, "%s %s %s %s %s", nbuf, - date ? date : "", cbuf, abuf, - invoice_status_sv(status)); + date ? date : "", cbuf, abuf, stbuf); items[i] = xstrdup(line); free(date); free(cust); free(status); + free(paid); } free(resp); items[n] = xstrdup("+ Ny faktura (^N)"); diff --git a/clients/screens_payroll.c b/clients/screens_payroll.c index 23d765f..8a27ec8 100644 --- a/clients/screens_payroll.c +++ b/clients/screens_payroll.c @@ -898,7 +898,7 @@ static void payroll_run_payslip(struct payroll_run *r) char *fname = jstr_dup(resp, "result.filename"); free(resp); if (b64 && fname) - pdf_save_and_open(b64, fname, "Lönebesked"); + file_save_and_open(b64, fname, "Lönebesked"); free(b64); free(fname); } @@ -1101,7 +1101,7 @@ static int64_t payroll_run_screen(struct app *a, int64_t id) { status, -1, NULL }, { totals, -1, NULL }, { "Förhandsvisa (F5)", 1, NULL }, - { "Bokför körning (^Enter/F9)", post_reason ? 0 : 1, + { "Bokför körning (F9)", post_reason ? 0 : 1, post_reason }, { "Lönebesked (PDF)", payslip_reason ? 0 : 1, payslip_reason }, { "AGI-underlag", agi_reason ? 0 : 1, agi_reason }, @@ -1124,7 +1124,7 @@ static int64_t payroll_run_screen(struct app *a, int64_t id) snprintf(title, sizeof title, "Ny lönekörning"); int editable = can_write && !posted; tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför" - " F5 = förhandsvisa ^Enter/F9 = bokför" + " F5 = förhandsvisa F9 = bokför" " Esc/q = tillbaka ^C = avsluta"); int ret = tui_form_run_actions(title, ff, 2, editable, acts, 7, NULL, &focus); diff --git a/clients/screens_settings.c b/clients/screens_settings.c index 8a31620..46ba201 100644 --- a/clients/screens_settings.c +++ b/clients/screens_settings.c @@ -199,6 +199,7 @@ struct setting_field { const char *const *choices; int nchoices; int mask; + int invoice_number; }; static void setting_save(struct app *a, const struct setting_field *f, @@ -218,9 +219,11 @@ static void setting_save(struct app *a, const struct setting_field *f, char *rr = client_rpc(&a->conn, "settings.set", a->session, a->org, args); free(args); if (rr && client_ok(rr)) { - if (strcmp(f->key, "default_series") == 0) + if (strcmp(f->key, "series_voucher") == 0) snprintf(a->default_series, sizeof a->default_series, "%s", value); + else if (strcmp(f->key, "series_ib") == 0) + snprintf(a->ib_series, sizeof a->ib_series, "%s", value); else if (strcmp(f->key, "attachment_dir") == 0) snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", value); @@ -283,12 +286,42 @@ static void settings_form(struct app *a, const char *title, acts[0].enabled = -1; } free(resp); + for (int i = 0; i < n; i++) { + if (!fields[i].invoice_number) + continue; + char *seq = client_rpc(&a->conn, "invoice.sequence_get", + a->session, a->org, "{}"); + if (seq && client_ok(seq)) + snprintf(vals[i], 512, "%lld", + (long long)jint_val(seq, "result.next_number", 1)); + free(seq); + } tui_form_hint("upp/ned/Home/End Enter = ändra F5 = uppdatera" " Esc/q = tillbaka ^C = avsluta"); int r = tui_form_run_actions(title, ff, n, can_edit, acts, show_password_status ? 1 : 0, NULL, focus); - if (r >= 0 && r < n) { + if (r >= 0 && r < n && fields[r].invoice_number) { + if (!(a->role[0] && strcmp(a->role, "owner") == 0)) { + tui_message(title, "Kräver ägarbehörighet."); + } else { + char *end = NULL; + long v = strtol(vals[r], &end, 10); + if (!end || *end || v < 1) { + tui_message(title, + "Nästa fakturanummer måste vara ett positivt" + " heltal."); + } else { + char args[64]; + snprintf(args, sizeof args, "{\"next_number\":%ld}", v); + char *rr = client_rpc(&a->conn, "invoice.sequence_set", + a->session, a->org, args); + if (!rr || !client_ok(rr)) + show_error(title, rr); + free(rr); + } + } + } else if (r >= 0 && r < n) { const char *val = vals[r]; if (fields[r].choices) val = fields[r].choices[choice[r]]; @@ -304,21 +337,30 @@ static void settings_form(struct app *a, const char *title, static const char *const SECURITY_CHOICES[] = { "starttls", "tls", "plain" }; static const struct setting_field INVOICE_SETTINGS[] = { - { "default_series", "Standardserie", NULL, 0, 0 }, - { "invoice_receivable_account", "Fordringskonto", NULL, 0, 0 }, - { "invoice_revenue_account", "Intäktskonto", NULL, 0, 0 }, - { "invoice_bankgiro", "Bankgiro", NULL, 0, 0 }, - { "invoice_our_ref", "Vår referens (faktura)", NULL, 0, 0 }, + { "invoice_next_number", "Nästa fakturanummer", NULL, 0, 0, 1 }, + { "invoice_receivable_account", "Fordringskonto", NULL, 0, 0, 0 }, + { "invoice_revenue_account", "Intäktskonto", NULL, 0, 0, 0 }, + { "invoice_bankgiro", "Bankgiro", NULL, 0, 0, 0 }, + { "invoice_our_ref", "Vår referens (faktura)", NULL, 0, 0, 0 }, + { "document_header_color", "Färg dokumenthuvud (#rrggbb)", NULL, 0, 0, 0 }, +}; + +static const struct setting_field SERIES_SETTINGS[] = { + { "series_voucher", "Manuella verifikat", NULL, 0, 0, 0 }, + { "series_invoice", "Fakturor", NULL, 0, 0, 0 }, + { "series_payroll", "Lön", NULL, 0, 0, 0 }, + { "series_bokslut", "Bokslut", NULL, 0, 0, 0 }, + { "series_ib", "Ingående balans", NULL, 0, 0, 0 }, }; static const struct setting_field SMTP_SETTINGS[] = { - { "smtp_host", "SMTP-server", NULL, 0, 0 }, - { "smtp_port", "SMTP-port", NULL, 0, 0 }, - { "smtp_user", "SMTP-användare", NULL, 0, 0 }, - { "smtp_from", "SMTP-avsändare", NULL, 0, 0 }, - { "smtp_reply_to", "SMTP-svarsadress", NULL, 0, 0 }, - { "smtp_security", "SMTP-säkerhet", SECURITY_CHOICES, 3, 0 }, - { "smtp_password", "SMTP-lösenord", NULL, 0, 1 }, + { "smtp_host", "SMTP-server", NULL, 0, 0, 0 }, + { "smtp_port", "SMTP-port", NULL, 0, 0, 0 }, + { "smtp_user", "SMTP-användare", NULL, 0, 0, 0 }, + { "smtp_from", "SMTP-avsändare", NULL, 0, 0, 0 }, + { "smtp_reply_to", "SMTP-svarsadress", NULL, 0, 0, 0 }, + { "smtp_security", "SMTP-säkerhet", SECURITY_CHOICES, 3, 0, 0 }, + { "smtp_password", "SMTP-lösenord", NULL, 0, 1, 0 }, }; static int settings_can_write(struct app *a) @@ -343,6 +385,14 @@ static void smtp_settings_screen(struct app *a) settings_can_write(a), 1, &sel); } +static void series_settings_screen(struct app *a) +{ + static int sel = 0; + settings_form(a, "Verifikationsserier", SERIES_SETTINGS, + (int)(sizeof SERIES_SETTINGS / sizeof SERIES_SETTINGS[0]), + settings_can_write(a), 0, &sel); +} + static const char *const SYSTEM_ITEMS[] = { "Skattetabeller", "Revision", @@ -435,6 +485,7 @@ static void company_data_screen(struct app *a) static const char *const COMPANY_ITEMS[] = { "Företagsuppgifter", "Fakturauppgifter", + "Verifikationsserier", "E-post (SMTP)", "Styrelseledamöter", MK_SECTION "Register", @@ -462,18 +513,21 @@ void company_screen(struct app *a) invoice_settings_screen(a); break; case 2: - smtp_settings_screen(a); + series_settings_screen(a); break; case 3: + smtp_settings_screen(a); + break; + case 4: board_screen(a); break; - case 5: + case 6: employees_screen(a); break; - case 6: + case 7: customers_screen(a); break; - case 7: + case 8: rules_screen(a); break; default: diff --git a/clients/screens_templates.c b/clients/screens_templates.c index 5777742..e55028d 100644 --- a/clients/screens_templates.c +++ b/clients/screens_templates.c @@ -234,7 +234,7 @@ static int template_form(struct app *a, const char *load_name) tui_rt_set_fields(&tf.rt, ff, 3); const char *title = load_name ? "Redigera mall" : "Ny mall"; const char *hint = "Enter = ändra fält Tab = byta fält F5 = validera" - " ^X = rensa rad ^Enter/F9 = spara Esc = avbryt"; + " ^X = rensa rad F9 = spara Esc = avbryt"; for (;;) { int rr = tui_rt_run(title, &tf.rt, hint); if (rr == -1) diff --git a/clients/screens_vouchers.c b/clients/screens_vouchers.c index 5e1b006..4cb6d9d 100644 --- a/clients/screens_vouchers.c +++ b/clients/screens_vouchers.c @@ -137,7 +137,7 @@ static int vd_key(void *ud, int ch) int asel = tui_select_list("Underlag", alines, (int)ctx->natts, 0, 0, &acursor, 0, "ta bort", 0); if (asel >= 0) { - attachment_download(a, aids[asel]); + attachment_view_or_download(a, aids[asel]); } else if (asel == -6 && acursor >= 0 && (size_t)acursor < ctx->natts) { char q[320]; @@ -743,6 +743,23 @@ static int64_t vform_post(struct vform *vf, int dry) replayed ? " — redan bokfört (idempotent)" : ""); free(ser); free(r); + if (strcmp(vf->series, a->default_series) != 0) { + yyjson_mut_doc *sd = yyjson_mut_doc_new(NULL); + yyjson_mut_val *so = yyjson_mut_obj(sd); + yyjson_mut_doc_set_root(sd, so); + yyjson_mut_obj_add_strcpy(sd, so, "key", "series_voucher"); + yyjson_mut_obj_add_strcpy(sd, so, "value", vf->series); + char *sargs = yyjson_mut_write(sd, 0, NULL); + yyjson_mut_doc_free(sd); + if (sargs) { + char *sr = client_rpc(&a->conn, "settings.set", + a->session, a->org, sargs); + free(sr); + free(sargs); + } + snprintf(a->default_series, sizeof a->default_series, "%s", + vf->series); + } free(args); return id; } @@ -789,21 +806,33 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p) if (p->description && *p->description) snprintf(vf.desc, sizeof vf.desc, "%s", p->description); if (p->bank_account && *p->bank_account) { - snprintf(vf.rows[0].account, sizeof vf.rows[0].account, "%s", - p->bank_account); int64_t amt = p->amount_ore < 0 ? -p->amount_ore : p->amount_ore; char tmp[32]; tui_kr_format(amt, tmp, sizeof tmp); - if (p->amount_ore > 0) + snprintf(vf.rows[0].account, sizeof vf.rows[0].account, "%s", + p->bank_account); + if (p->counter_account && *p->counter_account && + p->amount_ore > 0) { snprintf(vf.rows[0].debit, sizeof vf.rows[0].debit, "%s", tmp); - else if (p->amount_ore < 0) + snprintf(vf.rows[1].account, sizeof vf.rows[1].account, "%s", + p->counter_account); + snprintf(vf.rows[1].credit, sizeof vf.rows[1].credit, "%s", + tmp); + } else if (p->amount_ore > 0) { + snprintf(vf.rows[0].debit, sizeof vf.rows[0].debit, "%s", + tmp); + } else if (p->amount_ore < 0) { snprintf(vf.rows[0].credit, sizeof vf.rows[0].credit, "%s", tmp); + } } - if (p->description && *p->description) + if (p->description && *p->description) { snprintf(vf.rows[0].text, sizeof vf.rows[0].text, "%s", p->description); + snprintf(vf.rows[1].text, sizeof vf.rows[1].text, "%s", + p->description); + } } int text_w = COLS - 60; if (text_w < 8) @@ -836,7 +865,7 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p) tui_rt_normalize(&vf.rt); const char *hint = "Enter = ändra fält Tab = byta fält F4 = mall" " ^F = bifoga fil F5 = validera ^X = rensa rad" - " ^Enter/F9 = bokför Esc = avbryt"; + " F9 = bokför Esc = avbryt"; for (;;) { int rr = tui_rt_run("Nytt verifikat", &vf.rt, hint); if (rr == -1) diff --git a/clients/tui.c b/clients/tui.c index 30761a8..13da593 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -17,6 +17,12 @@ static int (*g_in)(void) = getch; static void (*g_frame)(const char *title); static void (*g_hints)(const char *hints); static void (*g_quit)(void); +static const char *g_list_hint_extra; + +void tui_list_hint_extra(const char *extra) +{ + g_list_hint_extra = extra; +} void tui_set_input(int (*fn)(void)) { @@ -1082,6 +1088,10 @@ int tui_select_list_hook(const char *title, char **items, int n, int start, allow_refresh ? " F5 = uppdatera" : "", allow_new ? " ^N = ny" : "", dbuf, allow_toggle ? " ^A = öppna/stäng" : ""); + if (g_list_hint_extra && *g_list_hint_extra) { + size_t hl = strlen(hint); + snprintf(hint + hl, sizeof hint - hl, " %s", g_list_hint_extra); + } list_draw(title, items, n, &v, hint); int ch = in_key(); int r = tui_nav_key(&v, ch, allow_new, archive_action != NULL, @@ -1190,6 +1200,7 @@ int tui_menu(const char *title, const char *const *items, int n, /* ------------------------------------------------------------------ */ static const char *g_form_hint; +static const char *g_form_hint_extra; static char g_status[512]; static char g_right[160]; @@ -1198,6 +1209,11 @@ void tui_form_hint(const char *hint) g_form_hint = hint; } +void tui_form_hint_extra(const char *extra) +{ + g_form_hint_extra = extra; +} + void tui_set_status(const char *status, const char *right) { snprintf(g_status, sizeof g_status, "%s", status ? status : ""); @@ -1531,6 +1547,174 @@ void tui_form_act_label(const struct tui_form_action *a, char *buf, size_t n) } } +static void action_key_name(int key, char *buf, size_t n) +{ + if (key >= KEY_F(1) && key <= KEY_F(12)) + snprintf(buf, n, "F%d", key - KEY_F(0)); + else if (key == TUI_KEY_CTRL_N) + snprintf(buf, n, "^N"); + else if (key == TUI_KEY_CTRL_X) + snprintf(buf, n, "^X"); + else if (key == 27) + snprintf(buf, n, "Esc"); + else if (key == '\n' || key == '\r' || key == KEY_ENTER) + snprintf(buf, n, "Enter"); + else if (key > 0 && key < 27) + snprintf(buf, n, "^%c", 'A' + key - 1); + else + snprintf(buf, n, "?"); +} + +void tui_action_label(const struct tui_action *a, char *buf, size_t n) +{ + const char *label = a && a->label ? a->label : ""; + + if (a && a->enabled == 0) { + const char *reason = + a->reason && *a->reason ? a->reason : "otillgänglig"; + snprintf(buf, n, "%s (%s)", label, reason); + } else { + snprintf(buf, n, "%s", label); + } +} + +void tui_action_hint(const struct tui_action *acts, int n, int max, char *buf, + size_t cap) +{ + size_t o = 0; + int shown = 0; + + if (!buf || cap == 0) + return; + buf[0] = '\0'; + for (int i = 0; i < n && (max <= 0 || shown < max); i++) { + char key[16], item[200]; + size_t len; + + if (acts[i].enabled != 1 || !acts[i].key) + continue; + action_key_name(acts[i].key, key, sizeof key); + snprintf(item, sizeof item, "%s%s = %s", shown ? " " : "", key, + acts[i].label ? acts[i].label : ""); + len = strlen(item); + if (o + len + 1 > cap) + break; + memcpy(buf + o, item, len + 1); + o += len; + shown++; + } + const char *more = "F2 = fler"; + size_t ml = strlen(more); + if (o + ml + (o ? 3 : 0) + 1 <= cap) + snprintf(buf + o, cap - o, "%s%s", o ? " " : "", more); +} + +int tui_action_menu(const char *title, const struct tui_action *acts, int n, + int *focus) +{ + struct tui_list_nav v; + unsigned char *ok; + char **items; + int ret = -1; + + if (!acts || n <= 0) + return -1; + memset(&v, 0, sizeof v); + v.n = n; + ok = xcalloc((size_t)n, 1); + items = xcalloc((size_t)n, sizeof(char *)); + for (int i = 0; i < n; i++) { + char line[192]; + + tui_action_label(&acts[i], line, sizeof line); + items[i] = xstrdup(line); + ok[i] = acts[i].enabled != -1; + } + v.selectable = ok; + v.sel = focus && *focus >= 0 && *focus < n ? *focus : 0; + if (!ok[v.sel]) + v.sel = nav_snap(&v, v.sel, 1); + v.numw = tui_num_width(n); + if (v.numw < 2) + v.numw = 2; + v.view = LINES - 4; + snprintf(v.gotolabel, sizeof v.gotolabel, "Gå till nummer: "); + for (;;) { + int num = 0; + + if (v.sel < 0 || v.sel >= n) { + v.sel = nav_snap(&v, 0, 1); + if (v.sel < 0) + break; + } + if (focus) + *focus = v.sel; + list_view_sync(&v); + if (g_frame) + g_frame(title); + for (int i = 0; i < v.top && i < n; i++) + if (ok[i]) + num++; + for (int idx = v.top; idx < n && idx < v.top + v.view; idx++) { + char line[832]; + if (!ok[idx]) { + snprintf(line, sizeof line, "%*s %s", v.numw, "", + items[idx]); + attron(tui_style_attrs(TUI_DIM, g_colours, 0)); + mvaddnstr(3 + (idx - v.top), 4, line, COLS - 6); + attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); + continue; + } + num++; + snprintf(line, sizeof line, "%*d. %s", v.numw, num, items[idx]); + if (acts[idx].enabled != 1) + attron(tui_style_attrs(TUI_DIM, g_colours, 0)); + if (idx == v.sel) + attron(A_REVERSE); + mvaddnstr(3 + (idx - v.top), 4, line, COLS - 6); + if (idx == v.sel) + attroff(A_REVERSE); + if (acts[idx].enabled != 1) + attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); + } + if (v.goto_active) { + move(LINES - 2, 2); + tui_style(TUI_ACCENT); + printw("%s%s", v.gotolabel, v.gotobuf); + tui_style_reset(); + clrtoeol(); + } else { + move(LINES - 2, 2); + clrtoeol(); + } + if (g_hints) + g_hints("upp/ned, 1-9 = hoppa, g = gå till, PgUp/PgDn, Home/End," + " Enter = utför Esc/q = avbryt"); + refresh(); + int ch = in_key(); + if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + if (!ok[v.sel]) + continue; + if (acts[v.sel].enabled != 1) { + tui_message(title, "%s", + acts[v.sel].reason && *acts[v.sel].reason + ? acts[v.sel].reason + : "Otillgänglig."); + continue; + } + ret = v.sel; + break; + } + if (tui_nav_key(&v, ch, 0, 0, 0, 0) == -1) + break; + } + for (int i = 0; i < n; i++) + free(items[i]); + free(items); + free(ok); + return ret; +} + /* Action rows sit under the fields, one blank line below them. */ static void form_draw_actions(const struct tui_form_action *acts, int na, int nf, int sel) @@ -1615,7 +1799,7 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, h = na > 0 ? (can_edit ? "upp/ned/Tab = flytta Enter = ändra/utför" - " F5 = uppdatera ^Enter/F9 = spara" + " F5 = uppdatera F9 = spara" " Esc/q = tillbaka ^C = avsluta" : "upp/ned/Tab = flytta Enter = utför" " F5 = uppdatera Esc/q = tillbaka" @@ -1623,11 +1807,16 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, " (endast behöriga kan ändra)") : (can_edit ? "upp/ned/Home/End Enter = ändra" - " F5 = uppdatera ^Enter/F9 = spara" + " F5 = uppdatera F9 = spara" " Esc/q = tillbaka ^C = avsluta" : "upp/ned/Home/End F5 = uppdatera" " Esc/q = tillbaka ^C = avsluta" " (endast behöriga kan ändra)"); + char hbuf[640]; + if (g_form_hint_extra && *g_form_hint_extra && + snprintf(hbuf, sizeof hbuf, "%s %s", h, g_form_hint_extra) < + (int)sizeof hbuf) + h = hbuf; tui_hints(h); g_form_hint = NULL; refresh(); diff --git a/clients/tui.h b/clients/tui.h index 6df27fb..1785a32 100644 --- a/clients/tui.h +++ b/clients/tui.h @@ -191,10 +191,33 @@ int tui_form_next(int sel, int n, int ch); int tui_form_run(const char *title, struct tui_form_field *f, int n, int can_edit, int *focus); void tui_form_hint(const char *hint); /* overrides the footer for the next run */ +/* Extra text appended to the form/list footer until changed (NULL clears). */ +void tui_form_hint_extra(const char *extra); +void tui_list_hint_extra(const char *extra); int tui_form_run_hook(const char *title, struct tui_form_field *f, int n, int can_edit, int (*key)(void *ud, int ch), void *ud, int *focus); +/* --- action registry (docs/TUI-GUIDELINES.md "Interaction model") --- */ +struct tui_action { + const char *id; /* stable, e.g. "customer.archive" */ + const char *label; /* Swedish UI text */ + int key; /* accelerator; 0 = menu only */ + int enabled; /* 1 runnable, 0 dim + reason, -1 heading */ + const char *reason; /* why a disabled action is dim */ +}; + +/* "label", or "label (reason)" when the action is disabled. */ +void tui_action_label(const struct tui_action *a, char *buf, size_t n); +/* Builds "F9 = spara F2 = fler", showing at most max keyed actions. */ +void tui_action_hint(const struct tui_action *acts, int n, int max, + char *buf, size_t cap); +/* Modal action list: arrows/Tab/Home/End/PgUp/PgDn/1-9/g navigate, Enter + runs (a disabled action shows its reason), Esc cancels. Returns the + chosen index or -1. *focus remembers the row. */ +int tui_action_menu(const char *title, const struct tui_action *acts, int n, + int *focus); + /* --- action list in a form (tui_form_run_actions) --- */ /* enabled: 1 = selectable and runs; 0 = dimmed but selectable (Enter shows disabled_reason in a message); -1 = non-selectable heading/status row, diff --git a/clients/ui.c b/clients/ui.c index ca6906f..cda0883 100644 --- a/clients/ui.c +++ b/clients/ui.c @@ -217,12 +217,17 @@ void app_refresh_context(struct app *a) free(resp); snprintf(a->default_series, sizeof a->default_series, "%s", "A"); + snprintf(a->ib_series, sizeof a->ib_series, "%s", "IB"); resp = client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); if (resp && client_ok(resp)) { - char *ser = jstr_dup(resp, "result.default_series"); + char *ser = jstr_dup(resp, "result.series_voucher"); if (ser && *ser) snprintf(a->default_series, sizeof a->default_series, "%s", ser); free(ser); + char *ib = jstr_dup(resp, "result.series_ib"); + if (ib && *ib) + snprintf(a->ib_series, sizeof a->ib_series, "%s", ib); + free(ib); } free(resp); @@ -467,26 +472,9 @@ static void safe_fname(const char *in, char *out, size_t n) out[o] = '\0'; } -static int write_pdf_b64(const char *b64, const char *path) -{ - unsigned char *data = NULL; - size_t len = 0; - if (!b64 || util_b64_decode(b64, strlen(b64), &data, &len) != 0) - return -1; - FILE *fp = fopen(path, "wb"); - if (!fp) { - free(data); - return -1; - } - size_t wrote = fwrite(data, 1, len, fp); - fclose(fp); - free(data); - return wrote == len ? 0 : -1; -} - -/* Open a saved PDF with xdg-open when a desktop session is present; +/* Open a saved file with xdg-open when a desktop session is present; otherwise (or on fork failure) show where it was saved. */ -static void open_pdf(const char *path, const char *title) +static void open_saved(const char *path, const char *title) { const char *display = getenv("DISPLAY"); const char *wayland = getenv("WAYLAND_DISPLAY"); @@ -510,8 +498,8 @@ static void open_pdf(const char *path, const char *title) tui_message(title, "Sparad: %s", path); } -void pdf_save_and_open(const char *b64, const char *filename, - const char *title) +static void save_cache_and_open(const unsigned char *data, size_t n, + const char *filename, const char *title) { char dir[512], path[700], safe[600]; pdf_cache_dir(dir, sizeof dir); @@ -521,11 +509,80 @@ void pdf_save_and_open(const char *b64, const char *filename, return; } config_mkdirs(path); - if (write_pdf_b64(b64, path) != 0) { + FILE *fp = fopen(path, "wb"); + if (!fp || (n > 0 && fwrite(data, 1, n, fp) != n)) { + if (fp) + fclose(fp); tui_message(title, "Kunde inte spara %s", path); return; } - open_pdf(path, title); + fclose(fp); + open_saved(path, title); +} + +void file_save_and_open(const char *b64, const char *filename, + const char *title) +{ + unsigned char *data = NULL; + size_t n = 0; + if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) { + tui_message(title, "Kunde inte avkoda innehållet."); + return; + } + save_cache_and_open(data, n, filename, title); + free(data); +} + +/* Show one attachment: text inline in a pager, anything else through the + desktop viewer (or a message with the saved path). */ +void attachment_open(struct app *a, int64_t att_id) +{ + char args[64]; + snprintf(args, sizeof args, "{\"id\":%lld}", (long long)att_id); + char *resp = + client_rpc(&a->conn, "attachment.get", a->session, a->org, args); + if (!resp || !client_ok(resp)) { + show_error("Kunde inte hämta underlaget", resp); + free(resp); + return; + } + char *fn = jstr_dup(resp, "result.filename"); + char *mime = jstr_dup(resp, "result.mime"); + char *b64 = jstr_dup(resp, "result.content_base64"); + unsigned char *data = NULL; + size_t n = 0; + if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) { + tui_message("Underlag", "Kunde inte avkoda innehållet."); + goto done; + } + int is_text = (mime && strncmp(mime, "text/", 5) == 0) || + (n > 0 && bytes_look_text(data, n)); + if (is_text && n <= 256 * 1024) { + char *copy = xmalloc(n + 1); + memcpy(copy, data, n); + copy[n] = '\0'; + tui_pager("Underlag", copy, NULL); + free(copy); + } else { + save_cache_and_open(data, n, fn && *fn ? fn : "underlag", "Underlag"); + } +done: + free(data); + free(b64); + free(mime); + free(fn); + free(resp); +} + +/* Enter on an attachment row: granska or ladda ned. */ +void attachment_view_or_download(struct app *a, int64_t att_id) +{ + static const char *const opts[] = { "Granska", "Ladda ned…" }; + int c = tui_choice_prompt("Underlag: ", opts, 2, 0); + if (c == 0) + attachment_open(a, att_id); + else if (c == 1) + attachment_download(a, att_id); } /* Fetch one attachment, save it to a prompted path and verify its hash. diff --git a/clients/ui.h b/clients/ui.h index b37eb4f..c546c91 100644 --- a/clients/ui.h +++ b/clients/ui.h @@ -42,6 +42,7 @@ struct app { int64_t employee_sel; /* last selected employee id in the list view */ int64_t payroll_sel; /* last selected payroll run id in the list view */ char default_series[16]; + char ib_series[16]; char attachment_dir[256]; long max_attachment_bytes; char fy_label[64]; @@ -78,9 +79,11 @@ char *rpc_dry(struct app *a, const char *cmd, const char *args); char *read_file_b64(const char *path); char *file_browser(struct app *a, const char *start_dir); void attachment_download(struct app *a, int64_t att_id); +void attachment_open(struct app *a, int64_t att_id); +void attachment_view_or_download(struct app *a, int64_t att_id); int64_t pick_voucher(struct app *a); -void pdf_save_and_open(const char *b64, const char *filename, - const char *title); +void file_save_and_open(const char *b64, const char *filename, + const char *title); /* generated-document helpers (reports, bokslut) */ void buf_line(struct buf *b, const char *fmt, ...) @@ -109,6 +112,7 @@ struct voucher_prefill { const char *date; /* YYYY-MM-DD, may be NULL */ const char *description; /* voucher text, may be NULL */ const char *bank_account; /* account number for the prefilled row */ + const char *counter_account; /* with amount > 0: credit this account */ int64_t amount_ore; /* > 0 debits the bank account, < 0 credits it */ }; diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a108f48..5499610 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -201,7 +201,65 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21). employed now but everything is multi-employee; no semester accrual; tax tables from day one; manual step-buttons (Bokför, Lönebesked, AGI-underlag, Betala skatt & avgifter); bank phase 3 (`bank_rule.*`) is - dropped. + dropped. +25. **Per-feature voucher series (2026-09-21)**: the single standardserie + setting is replaced by one setting per feature that posts vouchers: + `series_voucher` (manual vouchers and new templates, default `A`, + falling back to the legacy `default_series`), `series_invoice` (`F`), + `series_payroll` (`L`), `series_bokslut` (`Å`) and `series_ib` (`IB`). + All are editable (Bolaget → Verifikationsserier), 1–8 characters with + no control characters. The manual voucher form remembers the last + series it posted as `series_voucher`. Reports and SIE treat both the + configured `series_ib` and the historical `IB` series as ingående + balans, so old books keep working. "Nästa fakturanummer" is editable + in Bolaget → Fakturauppgifter (owner only). +26. **Invoicing follow-ups (2026-09-21)**: schema v12 adds + `invoice_rows.is_text` and `invoices.paid_date`/`payment_voucher_id`. + Invoices accept **text rows** (`"text": true`): description only, no + amount, excluded from totals and the posting voucher; at least one + priced row is required. The invoice detail gets `u = duplicera` (same + customer, rows and references, dates reset to today, due = today + + payment days) and `b = kvittera betalning`, which prefills the ordinary + voucher form (D `bank_account`, K `invoice_receivable_account`, both + editable, underlag attachable) and, after posting, calls + `invoice.pay`, which requires the voucher to credit the receivable with + exactly the invoice total. Partial payments are out of scope. Lists + show `betald <datum>`. SMTP: `smtp_from`/`smtp_reply_to` are validated + as e-mail addresses (settings.set and mail config), with the sender's + display name taken from the org name. +27. **Document header (2026-09-22)**: the invoice header bar no longer + draws the `MAKANDRA AB` Comfortaa outline; it prints the organization + name (`orgs.name`) in Helvetica-Bold, scaled down and truncated with + `...` if it would reach the `FAKTURA` wordmark, which stays. The shared + setting `document_header_color` (`#rrggbb`, default `#314c59`) colours + the header bar of **every** generated document — currently the invoice + and the lönebesked; `settings.set` refuses other values and the + renderers fall back to the default if a stored value is invalid. The + lönebesked prints the employer name in its header too, so the + `MAKANDRA AB` outlines are unused at runtime (kept in the generated + header). Images remain out of scope. +28. **TUI interaction model (2026-09-22, design)**: settled in the UX + session; the spec is `TUI-GUIDELINES.md` "Interaction model" and is not + implemented yet. Two focus modes: the navigation keys (`Tab`, arrows, + `Home`/`End`, `PgUp`/`PgDn`) only move focus/selection/scroll and never + mutate data, while an active field keeps caret semantics. `Enter` + activates the focused item only when it owns an action (menu item, + action row, opening list row); a plain report has none, so `Enter` does + nothing there — it never saves a whole form, deletes or posts. Entities + have one of three lifecycles: **register** (explicit `Spara` after + validation; drafts live in memory and in + `$XDG_CACHE_HOME/bokf/drafts.json`, marked `<UTKAST>`, deletable from + the list and from the editor), **document** (the form is the draft, + explicit post, immutable) and **settings** (explicit `Spara`; the + per-field autosave is dropped). Every savable form ends with a `Spara` + (or `Posta`) action row; commit is blocked while invalid and a server + error keeps the draft. Screens declare actions in one ordered + `struct tui_action` list that drives the `F2` menu, the accelerator keys + and the footer hints; `F2` only, no `§`; `Ctrl+Enter` is dropped as a + commit key (`F9` and the save action row remain). **Kunder pilot + implemented 2026-09-22** (`clients/drafts.[ch]`, `<UTKAST>`, the `Spara` + row, the `F2` menu, delete from list and editor); the other screens + follow. ## Completed work formerly listed under "Pending decisions" @@ -275,7 +333,15 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21). statement (Kapitas 2022-2026). **Decided 2026-09-19: no importer change.** Locked years stay locked and the source's closings stay in the books; the årsredovisning export flags incomplete jämförelsetal for those years and - points to the previous year's annual report. + points to the previous year's annual report. **Amended 2026-09-22**: the + income statement (and therefore INK2/SRU and the TUI resultatrapport) now + skips the same "Stäng ..." vouchers when they are SIE-imported, so a year + being declared shows its real figures; the balance sheet keeps them (the + result sits in 2099 and must not be counted twice). The source's own + #IB/#UB corrections that the vouchers do not reproduce (e.g. Makandra's FY + 2022/2023 result transfer is 17 857.63 kr short of that year's P&L) remain + a data divergence: the derived balance sheet is off by that amount until a + correction is booked in the current year. 11. ~~SIE import only into an empty fiscal year; consider broader import.~~ Chronological multi-year import works (CRLF, `#RAR 0`, zero rows, `#IB` rule handled); each year must still target an empty fiscal year. Note: diff --git a/docs/INVOICING.md b/docs/INVOICING.md index 7c6ce2e..de584cf 100644 --- a/docs/INVOICING.md +++ b/docs/INVOICING.md @@ -14,7 +14,8 @@ the generated PDF reproduces the existing document. - Customer register (name, address, momsreg.nr, e-mail, er referens, payment terms), owner-editable and audited. - One-page invoice document generated by bokfd, visually matching the - existing Google Sheets export (same grid, colours and wordmark). + existing Google Sheets export (same grid, colours and `FAKTURA` wordmark; + the header shows the organization name). - A configurable, always-increasing invoice number series per org, plus an OCR reference that Bankgiro accepts. - Issue in one action: number + PDF (stored as an immutable attachment) + @@ -49,13 +50,19 @@ Colours: | Body text | `#314c59` | | Header/table text on the bar | `#ffffff` | +The header bar's colour is the shared document setting +`document_header_color` (`#rrggbb`, default `#314c59`, also used by the +lönebesked). + Fonts: - Body: Helvetica (PDF base-14, metrically compatible with Arial); no embedding. -- Wordmark `MAKANDRA AB` and `FAKTURA`: Comfortaa Bold (SIL OFL) as - pre-generated vector outlines, drawn as filled paths. No font file or - TrueType machinery at runtime. +- Wordmark `FAKTURA`: Comfortaa Bold (SIL OFL) as pre-generated vector + outlines, drawn as filled paths; no font file or TrueType machinery at + runtime. The header's left side prints the organization `name` in + Helvetica-Bold, scaled down and truncated with `...` if it would reach + `FAKTURA`. Grid (points, origin top-left; refined against the originals in `tests/` golden comparisons): @@ -63,7 +70,7 @@ Grid (points, origin top-left; refined against the originals in | Element | x | y | |---|---|---| | Header bar (x 17.3–577.7) | 17.3 | 53.3–75.7 | -| Wordmark `MAKANDRA AB` (ink left/baseline) | 21.74 | 69.14 | +| Header name (org `name`, ink left/baseline) | 21.74 | 69.14 | | `FAKTURA` (ink right/baseline) | 576.87 | 69.14 | | Info labels (bold 7.285 pt), right-aligned | 113.98 | 101.11 + 14.71/row | | Info values (9.107 pt), left-aligned | 118.87 | same rows | @@ -206,6 +213,8 @@ CREATE TABLE invoices ( CHECK (status IN ('issued','credited')), document_id INTEGER, voucher_id INTEGER, + paid_date TEXT NOT NULL DEFAULT '', + payment_voucher_id INTEGER, last_sent_at TEXT, last_sent_to TEXT, created_at TEXT NOT NULL, @@ -214,7 +223,9 @@ CREATE TABLE invoices ( UNIQUE (org_id, number), FOREIGN KEY (org_id, customer_id) REFERENCES customers(org_id, id), FOREIGN KEY (org_id, document_id) REFERENCES attachments(org_id, id), - FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id) + FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id), + FOREIGN KEY (org_id, payment_voucher_id) + REFERENCES vouchers(org_id, id) ) STRICT; CREATE TABLE invoice_rows ( @@ -232,6 +243,7 @@ CREATE TABLE invoice_rows ( vat_code TEXT NOT NULL DEFAULT '25' CHECK (vat_code IN ('25','12','6','0','rc','eu')), account TEXT NOT NULL DEFAULT '', + is_text INTEGER NOT NULL DEFAULT 0, UNIQUE (org_id, id), UNIQUE (org_id, invoice_id, line_no), FOREIGN KEY (org_id, invoice_id) REFERENCES invoices(org_id, id) @@ -240,7 +252,19 @@ CREATE TABLE invoice_rows ( `vouchers.source` gains `invoice` (and later `credit`): the CHECK constraint must be widened. `invoice_rows` are written once at issue; `invoices` only -changes `status`, `last_sent_*` and (later) credit links. +changes `status`, `last_sent_*`, `paid_date`/`payment_voucher_id` and (later) +credit links. Schema v12 adds `invoice_rows.is_text` and the two payment +columns with forward `ALTER TABLE`s; the composite foreign key on +`payment_voucher_id` exists in fresh databases only (SQLite cannot add one +later), and `invoice.pay` validates the reference in code either way. + +A **text row** (`is_text`) is a free-text line in the table: only +`description` is meaningful, it has no quantity, unit, price or VAT and +contributes nothing to the totals or the posting voucher. It renders in the +description column only. Every invoice still needs at least one priced row. +When an invoice is marked **paid** (`invoice.pay`), `paid_date` is the +payment voucher's date and `payment_voucher_id` links it; partial payments +are not modelled. Customer seed: Andra bygg AB (Solna, SE559232855201, Eric Lejeby, 30), NZ Bygg AB (Bromma, SE559264837101, Valentyne Schnelle, 30), diff --git a/docs/PAYROLL.md b/docs/PAYROLL.md index b78aeba..ebf9272 100644 --- a/docs/PAYROLL.md +++ b/docs/PAYROLL.md @@ -216,9 +216,11 @@ the base is the gross. ## 6. Documents and TUI - **Lönebesked** (wave 2, done): `payroll.payslip` renders one A4 page per - employee and run with the invoice's visual language — dark `#314c59` - header bar, employer and employee blocks, gross, preliminary tax (shown - negative), net and the employer-contribution note; the personnummer is + employee and run with the invoice's visual language — the employer name in + a header bar in the shared document colour `document_header_color` + (default `#314c59`), employer and employee blocks, gross, preliminary tax + (shown negative), net and the employer-contribution note; the personnummer + is masked except the last four. `payroll.payslip_mail` stores the PDF as an `application/pdf` attachment on the run's voucher, links it with `voucher_attachments` and e-mails it through the org's `smtp_*` settings diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 76197d9..75ae963 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -374,26 +374,36 @@ resolved rows in a dry run. | Command | Args | Notes | |---|---|---| | `settings.get` | — | effective org settings (defaults included); secret values replaced by `_set` flags | -| `settings.set` | `key`, `value?` | known keys: `default_series`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account`, `invoice_bankgiro`, `invoice_our_ref`, `smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, `smtp_reply_to`, `smtp_security`, `smtp_password` | +| `settings.set` | `key`, `value?` | known keys: `default_series`, `series_voucher`, `series_invoice`, `series_payroll`, `series_bokslut`, `series_ib`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account`, `invoice_bankgiro`, `invoice_our_ref`, `document_header_color`, `smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, `smtp_reply_to`, `smtp_security`, `smtp_password` | -`default_series` (1–8 characters, e.g. `A`, `V-`, `A `) is used when -`voucher.post` carries no `series` and as the default series for new -templates. `attachment_dir` (a path, up to 255 characters) is accepted for +`series_voucher`, `series_invoice`, `series_payroll`, `series_bokslut` and +`series_ib` (1–8 characters, e.g. `A`, `V-`, `Å`) are the number series each +feature posts into: manual vouchers, `invoice.issue`, payroll runs and +payments, `bokslut.post` and ingående balans. They default to `A`, `F`, `L`, +`Å` and `IB`; `series_voucher` also falls back to the legacy +`default_series` (1–8 characters) when unset, and new templates default to +it. Reports and SIE count both the configured `series_ib` and the +historical `IB` series as ingående balans. `attachment_dir` (a path, up to +255 characters) is accepted for compatibility, but the TUI file browser now remembers the directory of the last picked attachment client-side. `bank_account` (digits only, up to 10 characters, default `1930`) is the account `bank.import` uses when the request carries no `account`. `invoice_receivable_account` (default `1510`) and `invoice_revenue_account` (default `3001`) are the receivable and default revenue account of invoice postings, digits only, up to 10 -characters. Verification ids are the concatenation of series and number +characters. `document_header_color` (`#rrggbb`, default `#314c59`) is the +background colour of the generated documents' header bars (invoice and +lönebesked); the invoice prints the org name on it. Verification ids are the +concatenation of series and number (`V-8`), and series are free-form: only an unbroken numbering per series is required. `smtp_host` (up to 255 characters, no control characters), `smtp_user` (up to -255), `smtp_from` and `smtp_reply_to` (up to 254), `smtp_port` (digits, -1–65535) and `smtp_security` (`starttls`, `tls` or `plain`, default -`starttls` when unset) configure the outgoing mail used when invoices are -sent. +255), `smtp_from` and `smtp_reply_to` (up to 254) must be e-mail addresses +(one `@`, no spaces), `smtp_port` (digits, 1–65535) and `smtp_security` +(`starttls`, `tls` or `plain`, default `starttls` when unset) configure the +outgoing mail used when invoices are sent. The sender's display name is the +organization name; `smtp_from` is the address. `smtp_password` is a secret setting. `settings.set` encrypts the value with AES-256-GCM under the key in the `BOKFD_SECRET_KEY` environment variable (32 @@ -441,7 +451,17 @@ linked to that voucher and each item carries that `voucher_id`. | `sru.export` | `fiscal_year`, `adjustments?`, `submitter?`, `assisted?`, `audited?`, `ignore_unmapped?` | `INFO.SRU` + `BLANKETTER.SRU` (ISO-8859-1, base64) | All reports are pure reads, respect locks, and return JSON rows. Amounts are -öre. `report.general_ledger` (huvudbok) returns account blocks: +öre. IB (ingående balans) for a balance account (asset, liability, equity) is +all earlier history, including earlier years' `IB` vouchers, plus this +year's `IB` vouchers; a P&L account (revenue, expense) restarts at zero at +every fiscal-year start, so its IB is only this year's `IB` vouchers and, +with a narrowed `from`, the year's movements before it. +`report.income_statement` (and therefore `sru.export` and the TUI +resultatrapport) ignores the source system's `"Stäng ..."` closing vouchers +in SIE-imported years (`source:"sie_import"`), where the P&L accounts are +closed straight to 2099 and would otherwise net to zero; the TUI +årsredovisning uses the same rule. `report.general_ledger` (huvudbok) +returns account blocks: `{"fiscal_year","from","to","last_voucher":{...},"accounts":[{"account", "name","ib_ore","debit_ore","credit_ore","ub_ore","rows":[{"series", "number","date","description","row_description","debit_ore","credit_ore", @@ -472,7 +492,10 @@ like the blankett; box 48 positive as filed) and returns `{"org_nr", bytes are ISO-8859-1, so write them verbatim to a `.xml` file. Both cover the whole fiscal year; the ledger's period can be narrowed with `from`/`to`. `report.vat` returns `{"from","to","boxes":[{"box":"05","label":"...","amount_ore":...}],"note"}`. -Rules sharing a box are summed into a single entry. `box 49` is the sum of +Rules sharing a box are summed into a single entry. The momsomföring +itself (any voucher with a row on 2650) and SIE-imported `"Stäng ..."` year +closings are left out, so a period that includes its own VAT settlement +still reports the underlying boxes. `box 49` is the sum of the moms boxes (`10`,`11`,`12`,`30`,`31`,`32`,`48`,`60`,`61`,`62`), so box 48 is signed like the blankett (ingående moms negative); underlag boxes do not change what is payable. @@ -509,7 +532,8 @@ support `dry_run`, which validates without writing. | `sie.import` | `content_base64` or `path`, `dry_run?` | one file per call; creates missing accounts and posts #VER as `source:"sie_import"`; only into an empty org fiscal year; `#IB` becomes an `IB` voucher when the year has no earlier history, otherwise the earlier vouchers carry the balances | SIE 4 files are written in CP437 with PC8 format, `#SIETYP 4`, `#FNR`, `#ORGNR`, -`#KONTO`, `#IB`, `#UB`, `#RES`, `#VER`, `#TRANS`. Import is the migration path +`#KONTO`, `#IB`, `#UB`, `#RES`, `#VER`, `#TRANS`. `#IB`/`#UB` are written for +balance accounts and `#RES` for P&L accounts, with IB as in §7.6. Import is the migration path from Fortnox/Visma/BL and must be dry-run first; it reports exactly what would be created. @@ -585,10 +609,11 @@ removes one link and is a `NOT_FOUND` when it does not exist. Both mutate | `invoice.sequence_set` | `next_number` (owner) | `next_number` | | `invoice.preview` | draft (below) | `content_base64`, `number`, `ocr`, `net_ore`, `vat_ore`, `total_ore` | | `invoice.issue` | draft, `dry_run?` | `id`, `number`, `ocr`, `document_id`, `voucher_id`, totals | -| `invoice.get` | `id` | header, `rows[]`, `document_id`, `voucher_id`, `last_sent_at`, `last_sent_to` | -| `invoice.list` | `customer_id?`, `status?` (`issued`/`credited`), `limit?` | `items[]`, newest first | +| `invoice.get` | `id` | header, `rows[]`, `document_id`, `voucher_id`, `paid_date`, `payment_voucher_id`, `last_sent_at`, `last_sent_to` | +| `invoice.list` | `customer_id?`, `status?` (`issued`/`credited`), `limit?` | `items[]`, newest first, with `paid_date` | | `invoice.pdf` | `id` | stored PDF as `content_base64` | | `invoice.send` | `id`, `to?` | `id`, `sent_to`, `at`; `dry_run` returns `to`, `subject` | +| `invoice.pay` | `id`, `voucher_id`, `dry_run?` | `id`, `number`, `paid_date`, `payment_voucher_id`, `voucher_series`, `voucher_number` | The draft object is the argument set shared by `invoice.preview` and `invoice.issue`: @@ -611,6 +636,13 @@ accounts are `ACCOUNT_NOT_FOUND`/`ACCOUNT_INACTIVE`. The customer must exist and be active (`NOT_FOUND`). A draft whose rows do not fit the single page is rejected with `TOO_LARGE`. +A row with `"text": true` is a free-text line: only `description` is used, +it has no quantity, unit, price or VAT, contributes nothing to the totals +and only prints its description in the document. Every draft still needs at +least one priced row (`INVALID_ARGS` otherwise, "invoice total must be +greater than zero"). `invoice.get` and `invoice.list` return rows with +`is_text`. + Numbering is a per-org, global series: `invoice_sequence.next_number` starts at 1, is set by the owner and is incremented by exactly one per issued invoice. The OCR reference is the number followed by its MOD10 (Luhn) check @@ -629,7 +661,9 @@ is the idempotency key). `invoice.sequence_set` and `invoice.issue` are audited; `invoice.issue` supports `dry_run`, which validates and renders but takes no number and writes nothing. `invoice.pdf` returns the stored document as base64 -(`JVBERi0` after decoding is the PDF magic). When the setting +(`JVBERi0` after decoding is the PDF magic). The document's header bar +prints the organization `name` and is coloured by `document_header_color` +(default `#314c59`; invalid stored values fall back to it). When the setting `invoice_bankgiro` is present it is printed in the document's Bankgiro field; `invoice_our_ref` (up to 64 characters) prefills the invoice form's "Vår referens". @@ -647,6 +681,15 @@ updated and the `invoice.send` audit entry stores `{id,to,subject}` only. `dry_run` validates configuration, recipient and stored document and returns the recipient and subject without sending or updating anything. +`invoice.pay` links a payment voucher (created by the client, normally from +the TUI's **Kvittera betalning** action, which prefills debit `bank_account` +and credit `invoice_receivable_account` in the ordinary voucher form) and +stamps `paid_date` with the voucher's date. The voucher must credit the +`invoice_receivable_account` (default `1510`) with exactly the invoice +total, else `INVALID_ARGS`; an already paid invoice and a `credited` one are +rejected (`CONFLICT` and `INVALID_ARGS`). `dry_run` validates without +writing, and the command is audited. + ### 7.11 Anställda (employees) The employee register. `personal_no` is checked for shape (10 or 12 digits, @@ -732,7 +775,8 @@ given date (default: today) and sets the run's `status` to `paid` with `payment_voucher_id`; paying twice is a `CONFLICT`. `payroll.payslip` renders one A4 lönebesked for an employee line of a posted -run: the employer header and footer, the employee name, the masked +run: the employer name in the header bar (coloured by +`document_header_color`), the employer footer, the employee name, the masked personnummer (`********-1234`, or `********` when the key is unavailable), period, pay date, tax table and column, then Bruttolön, the negative Preliminärskatt, a rule and Nettolön, and the note `Arbetsgivaravgifter @@ -857,6 +901,7 @@ Args: `name:type(values)[!][=default]`, `!` = required. | `invoice.list` | viewer | yes | no | no | `customer_id:int`, `status:enum(issued\|credited)`, `limit:int=200` | | `invoice.pdf` | viewer | yes | no | no | `id:int!` | | `invoice.send` | bookkeeper | yes | yes | yes | `id:int!`, `to:string` | +| `invoice.pay` | bookkeeper | yes | yes | yes | `id:int!`, `voucher_id:int!` | | `employee.list` | viewer | yes | no | no | `active_only:bool` | | `employee.get` | viewer | yes | no | no | `id:int!` | | `employee.create` | bookkeeper | yes | yes | yes | `name:string!`, `personal_no:string!`, `address:string`, `postal_code:string`, `city:string`, `bank_account:string`, `email:string`, `salary_account:string`, `monthly_salary_ore:int=0`, `tax_table:int=30`, `tax_column:int=1` | @@ -902,7 +947,10 @@ commands. Implemented screens (0.1.0-dev): - **Mallar** — list, create and edit templates in the same form style as vouchers (Tab, dynamic rows, F7 clear row, F5 validate, F9 save); archive keeps the template but hides it from the list. -- **Underlag** — inbox of unlinked attachments; `a` uploads a file. +- **Underlag** — inbox of unlinked attachments; `a` uploads a file and + Enter opens **Granska** (text in a pager, other files in the desktop + viewer) or **Ladda ned…**; the voucher detail's underlag list (`f`) works + the same way. - **Bankavstämning** — imported bank transactions (`bank.import`) matched against vouchers on the bank account, with suggestions; Enter matches the suggested voucher (or picks another), `u` unmatches, `a` imports a SEB CSV. @@ -912,16 +960,21 @@ commands. Implemented screens (0.1.0-dev): returns to the list. A failed auto-match keeps the posted voucher and shows the server error. - **Fakturor** — invoice list (`invoice.list`, newest first) with number, - date, customer, total and status (`utfärdad`/`krediterad`). Ctrl+N opens - the form, Enter the detail. The form has the customer picker, invoice/due - (due defaults from the customer's payment days) and delivery dates, er/var - referens and rows (beskrivning, antal, enhet, à-pris, moms, anm); `F5` - previews the real PDF (`invoice.preview`, nothing stored, no number - consumed), `Ctrl+Enter` issues (`invoice.issue`) and then asks - "Skicka faktura <nr> till <e-post>?". The detail shows header and rows; - `p` fetches the stored PDF (`invoice.pdf`) and `s` sends it - (`invoice.send`). In the list, `n` sets the next invoice number - (`invoice.sequence_get`/`sequence_set`, owner-only). + date, customer, total and status (`utfärdad`/`krediterad`/`betald + <datum>`). Ctrl+N opens the form, Enter the detail. The form has the + customer picker, invoice/due (due defaults from the customer's payment + days) and delivery dates, er/var referens and rows (beskrivning, antal, + enhet, à-pris, moms, anm); a row with only beskrivning is a free-text line + (`text` rows, no amount). `F5` previews the real PDF (`invoice.preview`, + nothing stored, no number consumed), `F9` issues (`invoice.issue`) and + then asks "Skicka faktura <nr> till <e-post>?". The detail shows header + and rows and offers `p = visa PDF` (`invoice.pdf`), `s = skicka` + (`invoice.send`), `u = duplicera` (a new draft with the same rows and + today's dates) and, on unpaid invoices, `b = kvittera betalning`: a + prefilled payment voucher (debit `bank_account`, credit + `invoice_receivable_account`) is opened in the ordinary voucher form and, + once posted, linked with `invoice.pay`. In the list, `n` sets the next + invoice number (`invoice.sequence_get`/`sequence_set`, owner-only). - **Kunder** — the customer register (name, address, postal code, city, VAT number, e-mail, your reference, payment days, notes). Ctrl+N creates, Enter edits (F5 validates with a dry run, Ctrl+Enter saves), `d` @@ -963,10 +1016,11 @@ commands. Implemented screens (0.1.0-dev): - **Bolaget** — the dashboard's hub for the org's master data: **Företagsuppgifter** (name, org number, VAT number, address, e-mail, phone, moms period, framework, fiscal-year start month), editable in - place by owners, others see it read-only; **Fakturauppgifter** - (standardserie, fordringskonto, intäktskonto, bankgiro, vår referens) and + place by owners, others see it read-only; **Fakturauppgifter** (nästa + fakturanummer (owner), fordringskonto, intäktskonto, bankgiro, vår + referens); **Verifikationsserier** (the per-feature series); and **E-post (SMTP)** (host, port, user, sender, reply-to, security, - password), both `settings.set` and open to bookkeepers; + password), the last three `settings.set` and open to bookkeepers; **Styrelseledamöter**; and the registers **Anställda**, **Kunder** and **Momsregler**. - **System** — the hub with **Skattetabeller** and **Revision**. The file diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 9818e1c..9199691 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -560,12 +560,12 @@ another voucher is posted in between) — clients must not persist it. ## 12. Migrations and versioning - `meta(key TEXT PRIMARY KEY, value TEXT)` holds `schema_version` (integer) - and `created_at`. Current version: **11** (v11 adds the employee e-mail, - v10 adds the payroll tables and the `payroll`/`payroll_tax` voucher - sources, v9 adds the invoicing tables and `invoice`, v8 the two bank - reconciliation tables, v7 makes attachments append-only, v3 replaces the - seeded moms rules with the corrected mapping; v2 adds the two template - tables). + and `created_at`. Current version: **12** (v12 adds invoice text rows and + the invoice payment link, v11 the employee e-mail, v10 the payroll tables + and the `payroll`/`payroll_tax` voucher sources, v9 the invoicing tables + and `invoice`, v8 the two bank reconciliation tables, v7 makes attachments + append-only, v3 replaces the seeded moms rules with the corrected mapping; + v2 adds the two template tables). - Migrations are forward-only, applied automatically at daemon start, each in one transaction. Before the first migration statement a consistent `VACUUM INTO` snapshot is written to diff --git a/docs/STATE.md b/docs/STATE.md index cf2efc8..5e9dc04 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -12,9 +12,22 @@ filing/year-end work remains. TUI is usable and exercised by `make test-pty` lönebesked); `make test` covers the server/protocol/ledger, the TUI widget unit tests and the docs consistency check. -## Resume here (2026-09-21) +## Resume here (2026-09-22) -- **Deployed**: `v0.1.58`, healthy on `nas` (Alpine runtime, static aarch64 +- **Deklaration 2025/2026 (org 2, 2026-09-22)**: the imported year is closed + by the source's `Stäng intäktskonton/kostnadskonton`, so the resultatrapport + (and the INK2/SRU derived from it) showed 0. `report.income_statement` now + skips those vouchers (deployed in `v0.1.65`), which gives + **241 817,48 kr** resultat efter skatt för 2025/2026. In the TUI the + "Bokfört resultat" section is absent for such years (the close goes straight + to 2099). Two **data drifts** to settle with the accountant before filing: + the imported FY 2022/2023 result transfer is **17 857,63 kr** short of that + year's P&L (its tax was never booked as an expense), so the derived balance + sheet does not balance by that amount; and 2099 is 1,07 kr off the P&L + result. Locked years stay locked — a correction belongs in the open year + (2026/2027). The owner can now run Bokslutshubben → Inkomstdeklaration. + +- **Deployed**: `v0.1.65`, healthy on `nas` (Alpine runtime, static aarch64 binaries cross-compiled on this machine — ~20 s, the host only assembles the image; image 33.7 MB, no `libssl3`). `v0.1.55` brought the payroll server waves (schema v11: employees, tax tables, lönebesked) and the @@ -22,9 +35,77 @@ unit tests and the docs consistency check. without section headings, the **System** hub (Skattetabeller, Revision) and the client-side remembered attachment directory; `v0.1.58` loads the system CA bundle explicitly so the static binaries can verify TLS (the - Skatteverket fetch, SMTP, static clients). The live database migrated - v9 → v11 on the v0.1.55 startup with the automatic pre-migration snapshot - in `var/db/backup/`. `main` and the tags are pushed to `nas`. + Skatteverket fetch, SMTP, static clients); `v0.1.59` adds the keyboard + protocols (though gnome-terminal/VTE cannot send Ctrl+Enter); `v0.1.60` + the per-feature voucher series (Bolaget → Verifikationsserier, IB + configurable with legacy `IB` still recognized), Nästa fakturanummer in + Fakturauppgifter and `F9`-only save hints; `v0.1.61` lets underlag be + granskade (text in a pager, other files in the desktop viewer) or + nedladdade from the voucher detail and the inbox; `v0.1.62` brings invoice + **text rows**, `u = duplicera`, `b = kvittera betalning` (`invoice.pay` + links a payment voucher) and rejects name-like `smtp_from` values + (schema v12: `invoice_rows.is_text`, `invoices.paid_date`/ + `payment_voucher_id`); `v0.1.63` prints the organization name in the + invoice and lönebesked headers and adds the shared document colour setting + `document_header_color`; `v0.1.64` is the TUI interaction-model spec (docs + only, no runtime change); `v0.1.65` carries earlier IB vouchers forward, + restarts P&L at each year, skips imported `Stäng` closings in the + income statement and the momsomföring in the VAT report. The live database migrated v9 → v11 on the v0.1.55 + startup and v11 → v12 on the v0.1.62 startup, each with the automatic + pre-migration snapshot in `var/db/backup/`. `main` and the tags are pushed + to `nas`. +- **Invoices (2026-09-22)**: the follow-ups are done and deployed in + `v0.1.62` — free-text rows, `u = duplicera`, `b = kvittera betalning` + (`invoice.pay`, strict receivable check) and `smtp_from` address + validation. `test_core` has `invoice_extras`; the pty suite has + `invoice-duplicate` and `invoice-pay`. Remaining invoice work is in the + backlog (credit notes, per-row account, kundreskontra, partial payments). +- **Document header (2026-09-22, deployed in `v0.1.63`)**: the invoice + header bar prints the organization name instead of the hard-coded + `MAKANDRA AB` outline (scaled down and truncated with `...` if long). + The shared document setting `document_header_color` (`#rrggbb`, default + `#314c59`, editable in Bolaget → Fakturauppgifter) colours the header bar + of both the invoice and the lönebesked, and the lönebesked prints the + employer name there too (it previously used the Comfortaa wordmark). The + invoice's `FAKTURA` outline is unchanged; the `MAKANDRA AB` outlines in + `src/wordmark.h` are now unused at runtime. +- **TUI interaction model (2026-09-22)**: the UX session settled two focus + modes, three entity lifecycles, explicit `Spara` in every savable form and + `<UTKAST>` drafts persisted to `$XDG_CACHE_HOME/bokf/drafts.json` + (deletable from both the list and the editor), plus one `tui_action` + registry per screen behind `F2` (no `§`). The **Kunder pilot is + implemented** (`clients/drafts.[ch]`, `tui_action_menu()`, + `tui_action_hint()`, `<UTKAST>`, `Spara` row, F2 menu, delete draft) with + unit tests, the pty scenarios `customer-draft`/`customer-draft-save` and a + green `make gate`. It is in `main` (`07f5b14`), pushed and part of `v0.1.65`. + **Next session**: get the human's Ctrl+R test feedback (Bolaget → Kunder: + Ctrl+N, type, Esc, F2, Spara, Radera utkast) and then continue the + rollout per backlog item 17: the other registers, explicit `Spara` in the + settings forms, `tui_rt` action menus. Review points kept in + `TUI-GUIDELINES.md`: drafts of encrypted fields (an employee's + personnummer) and `Enter` on action rows. Spec in `TUI-GUIDELINES.md` + "Interaction model"; decisions in `DECISIONS.md` #28. +- **IB carry-forward (2026-09-22, deployed in `v0.1.65`)**: + reports and `sie.export` carry earlier years' `IB` vouchers into the + opening balance (Makandra's 2021 IB voucher held aktiekapital 2081/1940, + which vanished from every later year) and restart P&L accounts at each + fiscal-year start (they used to accumulate since 2021). SIE writes + `#IB`/`#UB` only for balance accounts and `#RES` only for P&L. Makandra's + opening balances now show the 17 857,63 kr 2022/23 gap openly (balance + accounts' IB sums to that instead of 0). Merged together with + `eff/imported-closings`, whose "Stäng" skip now applies only to + `sie_import` vouchers. `test_core` has `ib_carry` and `imported_closings`. +- **Momsrapport (2026-09-22, deployed in `v0.1.65`)**: + `report.vat` (and `report.vat_eskd`) skips the momsomföring (vouchers with + a 2650 row) and SIE-imported `Stäng` closings. Makandra's 2025/26 report + was all zeros because V107 (Momsdeklaration) and V109 were counted; the + rules themselves were fine. `test_core` has `vat_settlement`. +- **Mail configuration**: Makandra AB (org 2) has **no** `smtp_*` settings + in bokf, so `invoice.send` there is `SMTP_NOT_CONFIGURED` (fine if + invoices are sent elsewhere — set them up when wanted). Mock AB (org 1) + has host/port/user/password but its stored `smtp_from` is the name + "Anders Bergsten": change it to an e-mail address in **Bolaget → E-post + (SMTP)** before sending; the new validation refuses names on save. - **Next tasks (payroll follow-ups)**: fold `payroll.settings_get/set` into `settings.get/set` (cmd_settings.c was busy during wave 1); settle the over-80k % rule (SKV 433 leaves it ambiguous — currently a clear @@ -34,15 +115,16 @@ unit tests and the docs consistency check. - **Done in the TUI wave**: `clients/screens_payroll.c` with Lönekörningar (list + Ctrl+N, run screen with F5 preview, Ctrl+Enter post after confirmation and the action rows Lönebesked, AGI-underlag, Betala skatt & - avgifter), Anställda under Företag and the Skattetabeller + avgifter), Anställda under Bolaget and the Skattetabeller fetch/import/status screen under System. A pty scenario posts a run and fetches its lönebesked, so the payslip PDF path is exercised end to end. - **Menu (2026-09-21)**: the dashboard is one flat list without section headings: Verifikat, Underlag, Bankavstämning, Mallar, Fakturor, Lönekörningar, Rapporter, Bokslut, Bolaget, System, Ingående balans, Räkenskapsår, Logga ut. **Bolaget** is the master-data hub - (Företagsuppgifter, Fakturauppgifter, E-post (SMTP), Styrelseledamöter - and the registers Anställda, Kunder, Momsregler); **System** holds + (Företagsuppgifter, Fakturauppgifter with Nästa fakturanummer, + **Verifikationsserier**, E-post (SMTP), Styrelseledamöter and the + registers Anställda, Kunder, Momsregler); **System** holds Skattetabeller and Revision. Inställningar is gone, and bilagornas mapp is no longer a setting: the file browser remembers the last pick directory in `tui.conf` and falls back to `$HOME`. `--screen settings` @@ -76,20 +158,28 @@ None open. Completed items that used to be listed here are archived in 13. ~~Payroll TUI (wave 3)~~ done (`clients/screens_payroll.c`); the remaining payroll follow-ups are listed under "Resume here". No employee is registered yet in the real orgs. -14. Invoice follow-ups when needed: credit notes (`invoice.credit`), per-row - account in the invoice form, kundreskontra view. +14. ~~Invoice text rows, duplicate and payment registration~~ done in + `v0.1.62` (schema v12). Remaining invoice follow-ups when needed: + credit notes (`invoice.credit`), per-row account in the invoice form, + kundreskontra view, partial payments. 15. `make test-pty` speed if it ever exceeds ~60 s: parallelise the independent scenarios (each has its own rig) and add `--only` symmetry; measure first. 16. Test fixtures (`t_fresh_org()`) + one test file per domain (`tests/core_<domain>.c`) so `--only` stops cascading; pilot with one domain. +17. Interaction-model rollout (spec: `TUI-GUIDELINES.md` "Interaction + model", decisions #28): **Kunder done** 2026-09-22 (widget layer + `tui_action`/`F2`, `clients/drafts.[ch]`, drafts/`<UTKAST>`/`Spara`, + pty scenarios), awaiting the human's Ctrl+R test feedback. Remaining: + the other register screens, explicit `Spara` in the settings forms + (replacing per-field autosave), and `tui_rt` action menus. Original entries for the struck items are in `docs/DECISIONS.md`. ## Environment / how to run -- **Deployed**: `scripts/deploy.sh` (latest `v0.1.58`, healthy on nas). +- **Deployed**: `scripts/deploy.sh` (latest `v0.1.65`, healthy on nas). Live daemon `tls:bokf.makandra.eu:8788`, token `~/.config/bokf/migration-token` (scopes `read,write`; owner-only actions like closing years must be done by the human in the TUI). Git remote @@ -132,6 +222,10 @@ Original entries for the struck items are in `docs/DECISIONS.md`. ## Known caveats +- Saving with Ctrl+Enter needs a terminal that speaks xterm + `modifyOtherKeys` level 2 or the Kitty keyboard protocol; gnome-terminal/VTE + sends neither, so the hints advertise `F9`, which works everywhere. + - Developer tooling: the `g_cmd_<domain>[]` tables in `src/cmd_*.c` carry declarative argument schemas (`CMD_ARGS`); `describe` emits them and the dispatcher validates before the handler runs. `make check` (part of @@ -145,24 +239,34 @@ Original entries for the struck items are in `docs/DECISIONS.md`. - Never commit unless the human asks. - SQLite files must not be backed up live with restic; use `backup.snapshot` (`VACUUM INTO`) and point restic at the snapshots. -- Schema version is 11 (v3 moms rules; v4/v6 year info; v5 org +- Schema version is 12 (v3 moms rules; v4/v6 year info; v5 org description/shares + board members; v7 attachments append-only triggers; v8 bank reconciliation; v9 invoicing + widened `vouchers.source` with a table rebuild; v10 payroll + `payroll`/`payroll_tax` sources, same - rebuild; v11 `employees.email`); forward migrations are in `db.c`. + rebuild; v11 `employees.email`; v12 `invoice_rows.is_text` and + `invoices.paid_date`/`payment_voucher_id`); forward migrations are in + `db.c`. ## Makandra driftstatus (org 2) - **Org**: Makandra AB, org 2. Räkenskapsår (id): 2022=3, 2023=4, 2024=5, 2025=6, 2026=7, **2027=2 (öppet, aktuellt)**. Bokslut/AR/deklaration görs för det år som är valt i sessionen. -- **FK2027**: importerade Kapitas-böcker + 28 bokförda verifikat (V21–V48) - för bank/skatt maj–sep 2026, samt V49 som makulerar en dubblett (V20). - 1930 stämmer mot banken utom **CDON 2 409 kr** (väntar på kvittots del - 2–4; bokförs när det kommer). 1630 = 40 721 (exakt enligt Skatteverket). -- **Underlag**: 279 attachment i org 2 (alla historikdokument + insamlade - underlag). Bank-/SKV-utdrag ligger i `~/Makandra AB/{bank,skatteverket}` - (Syncthing), källkorpus i `~/Downloads/Makandra AB-…/Bokföring/`. +- **FK2027**: importerade Kapitas-böcker + 30 bokförda verifikat (V21–V50) + för bank/skatt maj–sep 2026: V49 makulerar en dubblett (V20) och V50 är + Hetzner-förskottet (nedan). 1930 stämmer mot banken utom **CDON + 2 409 kr** (väntar på kvittots del 2–4; bokförs när det kommer) och + Hetzner-kortköpet 2026-09-21 som ännu inte är importerat. 1630 = 40 721 + (exakt enligt Skatteverket). +- **Hetzner-förskott (2026-09-22)**: V50, datum 2026-09-21 (bankens + bokföringsdatum), "Förskott Hetzner 100 EUR": **D 1790** 1 156,39 / + **K 1930** 1 156,39, med båda PDF:erna som underlag. Matchas mot banken + när nästa SEB-fil (efter 2026-09-18) importeras. Förbrukningen bokförs + när Hetzner-fakturorna kommer (se reglerna nedan). +- **Underlag**: 309 attachment i org 2 (alla historikdokument + insamlade + underlag, inkl. de två Hetzner-PDF:erna). Bank-/SKV-utdrag ligger i + `~/Makandra AB/{bank,skatteverket}` (Syncthing), källkorpus i + `~/Downloads/Makandra AB-…/Bokföring/`. - **Stängning**: 2022–2026 ska stängas av ägaren via **Räkenskapsår** i TUI:n; låt FK2027 vara öppen till nästa bokslut. - **Deklaration**: FK2026 är deklarerad av revisorn. FK2027 deklareras @@ -176,7 +280,11 @@ Original entries for the struck items are in `docs/DECISIONS.md`. historikårens P&L nettar noll pga källsystemets stängningar (AR hoppar över "Stäng"-verifikat i flerårsöversikten); utdelning bokförs vid stämman med mallen **Utdelning** (D 2099/K 2898); pappersoriginal finns i fysisk - pärm (får refereras i efterhand, även i stängda år). + pärm (får refereras i efterhand, även i stängda år); leverantörsförskott + och förbrukningssaldo (Hetzner) bokförs som **D 1790/K 1930** vid + påfyllning och **D 6540 + D 2645 / K 2614 / K 1790** vid förbrukning; + kortköp bokförs på **bankens bokföringsdatum** (lättare att härleda och + matcha vid import). - **Lön (2026-09-21)**: bara ägaren är anställd, men schema och kommandon är fleranvändarklara. Lönemotorn (schema v11: anställda, skattetabeller, lönekörningar, lönebesked + mejl) och TUI-sektionen Lön är klara, men diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index 190a5c5..1c201aa 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -4,6 +4,124 @@ Rules for the ncurses client so every view behaves the same. When in doubt, copy the behaviour of the voucher list / voucher form; they are the reference implementations. Inspired by Midnight Commander, htop, mutt and calcurse. +The **Interaction model** section is the agreed target (settled 2026-09-22, +`DECISIONS.md` #28). The **Kunder** pilot is implemented: drafts in +`$XDG_CACHE_HOME/bokf/drafts.json`, `<UTKAST>` marking, the explicit `Spara` +row, the `F2` action menu and draft deletion work there. Every other screen +still follows the sections below; the interaction model wins where they +conflict as each screen moves over. + +## Interaction model (target, 2026-09-22) + +Every entity has **one lifecycle**, every form **one commit gesture**, and +every action **one declaration**. Nothing is written to the backend +implicitly. + +### Two focus modes + +The cursor is always in one of two modes: + +- **Navigation** — `Tab`/`Shift-Tab`, arrows, `Home`/`End`, `PgUp`/`PgDn` + only move focus, selection or the viewport. They never mutate data, never + save and never run an action. +- **Editing** — the focused field is reverse video with a caret. Inside a + field, `←`/`→`/`Home`/`End` move the caret, `Backspace`/`Del`/`Ctrl+U` + edit the text and `Up`/`Down` leave the field. `Enter` commits the field + and advances; `Esc` restores it (the scratch-copy semantics stay). + +`Enter` activates the focused item **when that item owns an action** (a menu +item, an action row, a list row that opens a detail). A report, a pager or a +blank area has no such item, so `Enter` does nothing there. `Enter` never +saves a whole form, never deletes and never posts. + +### Entity lifecycle + +| Class | Draft | Commit event | `Esc`/`q` | Examples | +|---|---|---|---|---| +| **Register** | in memory + local draft file | explicit `Spara` (the save action row, `F9`) after validation | back; the draft stays | customers, employees, templates, momsregler, org data | +| **Document** | the form is the draft | explicit `Posta` (the save action row, `F9`); immutable once written | back; confirm only when dirty | verifikat, fakturor, löneruns, bokslut | +| **Settings** | an edit buffer until `Spara` | explicit `Spara` | back | faktura/SMTP/serie-inställningar | + +- Every savable form ends with a visible commit action row (`Spara`, or + `Posta` for documents); `Enter` on the focused row commits (it is an item + with an action), `F9` is the accelerator. +- `Ctrl+Enter` is **dropped** as a commit key: gnome-terminal/VTE cannot + send it. The save action row and `F9` are the only commit gestures, which + makes every terminal behave the same. +- Commit is blocked while the data is invalid: the first invalid field is + focused and its error shown. The draft is untouched. A server error at + commit keeps the draft as well; only a successful command removes it. +- `Ctrl+R` (reload) and `Ctrl+C` never flush drafts to the backend; the + client-side draft file makes them survive both, so no work is lost. + +### Drafts + +- Every non-committed edit is a **draft**, held in memory and mirrored to + `$XDG_CACHE_HOME/bokf/drafts.json` (mode 0600, atomic replace) on every + change. This is the "nothing is ever lost" guarantee — `Ctrl+R`, a crash + and `Ctrl+C` included. +- A draft is keyed by `(org, entity, id)`; a new entity gets a temporary id + and shows up in its list immediately. +- Drafts are marked `<UTKAST>` directly after the row number in lists + (`3. <UTKAST> Namn`) and in the editor's frame title (`Kund <UTKAST>`). +- **Delete draft** is an action both on the list row (via `F2`) and inside + the editor, with a confirmation. It removes the memory and file draft and + never touches the backend. A successful commit removes the draft too. +- Drafts are client-local and never synced; another client sees the last + committed value. +- When the backend entity is gone at commit time (`NOT_FOUND`), the editor + offers "spara som ny" or "radera utkast". +- Review point: a draft of an encrypted field (an employee's personnummer) + puts plaintext in the cache file. 0600 is the same protection as the + Bitwarden session file; decide whether such fields are excluded from + drafts. + +### Actions and the `F2` menu + +Screens declare actions, never keys: + +```c +struct tui_action { + const char *id; /* stable, e.g. "customer.archive" */ + const char *label; /* Swedish UI text */ + int key; /* accelerator; 0 = menu only */ + int enabled; /* 1 runnable, 0 dimmed with a reason, -1 heading */ + const char *reason; /* why a disabled action is dim */ +}; +``` + +- One ordered action list per context drives everything: `F2` opens the + `Åtgärder` menu, the same list dispatches the accelerator keys and builds + the footer hint. A key can no longer exist outside the registry. +- The menu is sectioned: **Aktuell rad** (item actions), **Skärmen** (save, + delete draft, attach, …), **Globalt** (`F5` uppdatera, `Ctrl+R` ladda om, + …). Destructive actions are last and still ask for confirmation. +- `Enter` in the menu runs the highlighted action; disabled actions are dim + with their reason (as in `tui_form_action` today); `Esc` closes. The + actions that complex forms hide behind hotkeys today live here unchanged. +- The footer shows at most the two or three most important contextual + actions plus `F2 = fler`. Universal navigation keys (`Tab`, arrows, + `PgUp`/`PgDn`, `Home`/`End`) are not repeated there. +- The session's key decisions: letter accelerators stay, `F9` is the only + commit key (`Ctrl+Enter` is dropped) and `F2` only — no `§` binding (it is + not reliably encodable across terminals). + +### Implementation status + +1. Done: `struct tui_action`, `tui_action_menu()` and `tui_action_hint()` in + `clients/tui.[ch]`, unit-tested in `tests/test_tui.c`. +2. Partly done: the Kunder list builds its `F2` actions in a key hook and + appends `F2 = åtgärder` to the footer via `tui_list_hint_extra()`; other + lists have no actions yet, and `tui_rt` still uses its own key branches. +3. Done for Kunder: `clients/drafts.[ch]` (JSON store, atomic 0600 write, + temporary ids, `<UTKAST>` marking, delete action), unit-tested. +4. Done for Kunder: draft/`Spara` model with the `Spara` action row, + `F2` menu and draft deletion from both the list and the editor. The + other register screens and the settings forms (explicit `Spara`, no + per-field autosave) are next. +5. Done for Kunder: pty scenarios `customer-draft` and + `customer-draft-save` cover create, edit, `<UTKAST>`, delete and save. + ## Session start After login the org picker ("Välj organisation att representera") is always @@ -29,11 +147,12 @@ there. | `a` | Add/upload (Underlag) | | `c` | Correct (voucher detail) | | `d` | Delete/arkivera the selected row (only where the action exists; asks for confirmation) | -| `f` | Voucher detail: list the voucher's underlag — Enter fetches, `d` removes the link (asks first). Underlag: Enter fetches | +| `f` | Voucher detail: list the voucher's underlag — Enter opens Granska (text in a pager, PDFs/images in the desktop viewer) or Ladda ned…, `d` removes the link (asks first). Underlag: Enter does the same | +| `u` / `b` | Faktura detail: `u` duplicates the invoice into a new draft (same rows, dates reset to today), `b` (unpaid invoices) prefills and posts the payment voucher, then marks the invoice paid | | `Ctrl+F` | Attach a file via the file browser (voucher form and voucher detail) | | `k` | Underlag: link the highlighted attachment to a voucher picked from a list | | `Ctrl+X` | Clear the current row — only inside row editors (never "new") | -| `Ctrl+Enter` | Save/post the current form. Enabled via xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol; terminals that send neither keep `F9` working, and the hints show `^Enter/F9` | +| `Ctrl+Enter` | Save/post the current form. Needs xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol (xterm, kitty, foot, WezTerm); gnome-terminal/VTE sends neither, so the hints advertise `F9`, which works everywhere. The interaction model drops `Ctrl+Enter` entirely — don't add it to new views | Every screen prints its keys in the footer via `hints()`. If a key exists, the footer shows it; if the footer shows it, the key works. Control keys are @@ -43,7 +162,7 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. - Rows are numbered `NN. text`, right-aligned so 2- and 3-digit numbers line up. - Verifikation ids are shown concatenated as `series+number` (`V-8`, `A8`), - using the org's `default_series` (Bolaget → Fakturauppgifter) for new + using the org's `series_voucher` (Bolaget → Verifikationsserier) for new vouchers. - The last row may be an action (e.g. `+ Nytt verifikat (Ctrl+N)`); selecting it runs the action instead of opening a detail view. @@ -235,10 +354,14 @@ only place that touches ncurses. Rules: ## Adding a view — checklist 1. Data comes from public protocol commands only. -2. Wrap the screen in `frame()`/`hints()`; return `Esc`/`q` to the parent. +2. Wrap the screen in `tui_frame()` and let the widgets carry the footer + hints; return `Esc`/`q` to the parent. 3. Use `tui_menu`/`tui_select_list` instead of writing a new loop; pass `allow_new`/`allow_refresh` so the universal keys apply. 4. Forms use the shared editor (`tui_edit_field`, `tui_prompt_into`, `tui_date_prompt_into`, `tui_amount_prompt_into`) and the row helpers. 5. Support `F5` if the data can change elsewhere. 6. Update `PROTOCOL.md` §8 and this file if you add a new key or interaction. +7. Declare the screen's actions in one `tui_action` list (once the + interaction model is implemented); dispatch, the `F2` menu and the footer + hint all read that list, so a key cannot exist without a visible action. diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py index f5d5b7f..4fee4df 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -41,6 +41,7 @@ from pathlib import Path ORG_NAME = "Test AB" ORG_NR = "5560123456" +DOWNLOAD_PATH = f"/tmp/bokf-golden-dl-{os.getpid()}.txt" KEYS = { "enter": "\r", @@ -59,6 +60,7 @@ KEYS = { "ctrlenter-kitty": "\x1b[13;5u", "f5": "\x1b[15~", "f9": "\x1b[20~", + "f2": "\x1bOQ", } # {org_name} {org_nr} {fy_label} {fy_start} {fy_end} are substituted at run @@ -104,6 +106,42 @@ SCENARIOS = [ "expect": ["Benämning", "Debet", "Kredit", "Underlag", "kvitto.txt"], }, + { + "keys": ["f"], + "expect": ["Underlag", "kvitto.txt"], + }, + { + "keys": ["enter"], + "expect": ["Underlag: < Granska >"], + }, + { + "keys": ["enter"], + "expect": ["piltangenter/PgUp/PgDn rullar", "kvitto"], + }, + { + "keys": ["esc"], + "expect": ["Benämning", "Underlag"], + }, + { + "keys": ["f", "enter", "right"], + "expect": ["Underlag: < Ladda ned… >"], + }, + { + "keys": ["enter"], + "expect": ["Spara underlag: "], + }, + { + "keys": [DOWNLOAD_PATH, "enter"], + "expect": ["Sparat"], + }, + { + "keys": ["enter"], + "expect": ["piltangenter/PgUp/PgDn rullar", "kvitto"], + }, + { + "keys": ["esc"], + "expect": ["Benämning", "Underlag"], + }, ], }, { @@ -194,11 +232,11 @@ SCENARIOS = [ }, { "keys": ["enter"], - "expect": ["Matchat med A3"], + "expect": ["Matchat med A2"], }, { "keys": ["enter"], - "expect": ["→ A3", "0 omatchade"], + "expect": ["→ A2", "0 omatchade"], }, { "keys": ["ctrln"], @@ -221,6 +259,64 @@ SCENARIOS = [ "expect": ["Kunder", "Testkund AB"], }, { + "name": "customer-draft", + "screen": "customers", + "expect": ["Kunder", "Testkund AB"], + "steps": [ + { + "keys": ["ctrln"], + "expect": ["Ny kund", "<UTKAST>"], + }, + { + "keys": ["enter", "Utkastkund", "enter"], + "expect": ["Ny kund", "<UTKAST>", "Utkastkund"], + }, + { + "keys": ["esc"], + "expect": ["Kunder", "<UTKAST> Utkastkund"], + }, + { + "keys": ["end", "up"], + "expect": ["<UTKAST> Utkastkund"], + }, + { + "keys": ["f2"], + "expect": ["Åtgärder", "Öppna", "Radera utkast"], + }, + { + "keys": ["down", "enter"], + "expect": ["Radera utkastet?"], + }, + { + "keys": ["enter"], + "expect": ["Kunder", "Testkund AB"], + }, + ], + }, + { + "name": "customer-draft-save", + "screen": "customers", + "expect": ["Kunder", "Testkund AB"], + "steps": [ + { + "keys": ["ctrln"], + "expect": ["Ny kund", "<UTKAST>"], + }, + { + "keys": ["enter", "Sparad kund", "enter"], + "expect": ["Sparad kund", "<UTKAST>"], + }, + { + "keys": ["f9"], + "expect": ["Sparat."], + }, + { + "keys": ["enter"], + "expect": ["Kunder", "Sparad kund"], + }, + ], + }, + { "name": "invoice-form", "screen": "invoices", "steps": [ @@ -231,6 +327,52 @@ SCENARIOS = [ ], }, { + "name": "invoice-duplicate", + "screen": "invoices", + "steps": [ + { + "keys": ["enter"], + "expect": ["Att betala", "12 500,00"], + }, + { + "keys": ["u"], + "expect": ["Ny faktura", "Testkund AB", + "Konsulttjänster"], + }, + { + "keys": ["esc"], + "expect": ["Faktura", "Att betala"], + }, + ], + }, + { + "name": "invoice-pay", + "screen": "invoices", + "steps": [ + { + "keys": ["enter"], + "expect": ["Att betala", "12 500,00"], + }, + { + "keys": ["b"], + "expect": ["Nytt verifikat", "1930", "1510", + "Betalning faktura"], + }, + { + "keys": ["f9"], + "expect": ["Bokfört"], + }, + { + "keys": ["enter"], + "expect": ["kvitterad"], + }, + { + "keys": ["enter"], + "expect": ["betald", "Betalningsverifikat"], + }, + ], + }, + { "name": "employees", "screen": "employees", "expect": ["Anställda", "Testanställd", "Ny anställd"], @@ -291,10 +433,28 @@ SCENARIOS = [ "name": "company", "screen": "company", "expect": ["Bolaget", "Företagsuppgifter", "Fakturauppgifter", - "E-post (SMTP)", "Anställda", "Kunder", "Momsregler"], + "Verifikationsserier", "E-post (SMTP)", "Anställda", + "Kunder", "Momsregler"], "steps": [ { + "keys": ["2"], + "expect": ["Fakturauppgifter", "Nästa fakturanummer"], + }, + { + "keys": ["esc"], + "expect": ["Bolaget", "Verifikationsserier"], + }, + { "keys": ["3"], + "expect": ["Verifikationsserier", "Manuella verifikat", + "Ingående balans"], + }, + { + "keys": ["esc"], + "expect": ["Bolaget", "Fakturauppgifter"], + }, + { + "keys": ["4"], "expect": ["E-post (SMTP)", "SMTP-server", "lämna tomt för oförändrat"], }, @@ -303,7 +463,7 @@ SCENARIOS = [ "expect": ["Bolaget", "Företagsuppgifter"], }, { - "keys": ["5"], + "keys": ["6"], "expect": ["Anställda", "Testanställd"], }, { @@ -1044,6 +1204,10 @@ def main(argv): file=sys.stderr) failures = max(failures, 1) finally: + try: + os.unlink(DOWNLOAD_PATH) + except OSError: + pass if daemon and daemon.poll() is None: try: os.killpg(daemon.pid, signal.SIGTERM) diff --git a/src/cmd_bokslut.c b/src/cmd_bokslut.c index c813923..f0ddee9 100644 --- a/src/cmd_bokslut.c +++ b/src/cmd_bokslut.c @@ -88,6 +88,9 @@ static yyjson_mut_val *h_bokslut_post(struct req *r) } int64_t result_before = 0; + char ib_series[16]; + db_setting_copy(r->db, r->org_id, "series_ib", "IB", ib_series, + sizeof ib_series); if (sqlite3_prepare_v2( r->db, "SELECT COALESCE(SUM(r.credit_ore)-SUM(r.debit_ore),0)" @@ -95,11 +98,13 @@ static yyjson_mut_val *h_bokslut_post(struct req *r) " JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND v.fiscal_year_id=?2 AND v.series<>'IB'" + " AND v.series<>?3" " AND a.type IN ('revenue','expense') AND a.number NOT LIKE '89%'", -1, &st, NULL) != SQLITE_OK) return db_error(r); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, fy_id); + sqlite3_bind_text(st, 3, ib_series, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) == SQLITE_ROW) result_before = sqlite3_column_int64(st, 0); sqlite3_finalize(st); @@ -154,6 +159,9 @@ static yyjson_mut_val *h_bokslut_post(struct req *r) plan[np++] = (typeof(plan[0])){ "Resultatdisposition", drows, 2 }; } + char series[16]; + db_setting_copy(r->db, r->org_id, "series_bokslut", "Å", series, + sizeof series); yyjson_mut_val *vouchers = yyjson_mut_arr(r->rdoc); for (size_t i = 0; i < np; i++) { struct ledger_post_opts o; @@ -165,6 +173,7 @@ static yyjson_mut_val *h_bokslut_post(struct req *r) o.description = plan[i].desc; o.rows = plan[i].rows; o.nrows = plan[i].nrows; + o.series = series; o.dry_run = r->dry_run; struct ledger_error e; char *json = NULL; diff --git a/src/cmd_invoices.c b/src/cmd_invoices.c index 0892ac8..a9d3fb1 100644 --- a/src/cmd_invoices.c +++ b/src/cmd_invoices.c @@ -11,6 +11,7 @@ #include "db.h" #include "invoice.h" #include "ledger.h" +#include "mail.h" #include "pdf.h" #include "secret.h" #include "smtp.h" @@ -139,6 +140,7 @@ struct draft_line { const char *note; const char *vat_code; char account[16]; + int is_text; }; struct invoice_draft { @@ -211,6 +213,21 @@ static int draft_line_parse(struct req *r, yyjson_val *item, size_t no, failf(r, "INVALID_ARGS", "row %zu: description is required", no); return -1; } + int is_text = 0; + arg_bool(item, "text", &is_text); + if (is_text) { + l->article_no = NULL; + l->description = description; + l->quantity_milli = 1; + l->unit = ""; + l->unit_price_ore = 0; + l->amount_ore = 0; + l->note = ""; + l->vat_code = "0"; + l->is_text = 1; + snprintf(l->account, sizeof l->account, "%s", default_account); + return 0; + } const char *qty = arg_str(item, "quantity"); int64_t quantity_milli = 0; if (parse_quantity(qty, &quantity_milli) != 0) { @@ -363,6 +380,7 @@ struct invoice_view { char seller_org_nr[64]; char seller_vat_nr[64]; char bankgiro[64]; + char header_color[8]; char customer_name[256]; char customer_address[1024]; char customer_postal[64]; @@ -450,6 +468,9 @@ static int invoice_view_fill(struct req *r, const struct invoice_draft *d, bankgiro && *bankgiro ? bankgiro : ""); free(bankgiro); + db_setting_copy(r->db, r->org_id, "document_header_color", "", + v->header_color, sizeof v->header_color); + v->lines = xcalloc(d->nlines, sizeof *v->lines); for (size_t i = 0; i < d->nlines; i++) { v->lines[i].article_no = d->lines[i].article_no; @@ -460,6 +481,7 @@ static int invoice_view_fill(struct req *r, const struct invoice_draft *d, v->lines[i].amount_ore = d->lines[i].amount_ore; v->lines[i].note = d->lines[i].note; v->lines[i].vat_code = d->lines[i].vat_code; + v->lines[i].is_text = d->lines[i].is_text; } v->doc.seller.name = v->seller_name; @@ -471,6 +493,7 @@ static int invoice_view_fill(struct req *r, const struct invoice_draft *d, v->doc.seller.org_nr = v->seller_org_nr; v->doc.seller.vat_nr = v->seller_vat_nr; v->doc.seller.bankgiro = v->bankgiro; + v->doc.header_color = v->header_color; v->doc.customer.name = v->customer_name; v->doc.customer.address = v->customer_address; v->doc.customer.postal_code = v->customer_postal; @@ -670,8 +693,8 @@ static int invoice_store_invoice(struct req *r, const struct invoice_draft *d, r->db, "INSERT INTO invoice_rows(org_id,invoice_id,line_no,article_no," "description,quantity_milli,unit,unit_price_ore,amount_ore,note," - "vat_code,account)" - " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", + "vat_code,account,is_text)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)", -1, &st, NULL) != SQLITE_OK) { db_error(r); return -1; @@ -689,6 +712,7 @@ static int invoice_store_invoice(struct req *r, const struct invoice_draft *d, sqlite3_bind_text(st, 10, l->note ? l->note : "", -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 11, l->vat_code, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 12, l->account, -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 13, l->is_text); rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { @@ -821,6 +845,9 @@ static yyjson_mut_val *h_invoice_issue(struct req *r) invoice_store_attachment(r, &v, pdf, pdf_len, &attachment_id) != 0) goto done; + char series[16]; + db_setting_copy(r->db, r->org_id, "series_invoice", "F", series, + sizeof series); struct ledger_post_opts o; memset(&o, 0, sizeof o); o.org_id = r->org_id; @@ -831,6 +858,7 @@ static yyjson_mut_val *h_invoice_issue(struct req *r) o.rows = vrows; o.nrows = vn; o.source = "invoice"; + o.series = series; o.dry_run = r->dry_run; o.already_in_tx = 1; struct ledger_error e; @@ -888,12 +916,12 @@ static yyjson_mut_val *invoice_row_json(struct req *r, sqlite3_stmt *st) return db_row_json(r->rdoc, st, "line_no:i,article_no:s,description:s," "quantity_milli:i,unit:s,unit_price_ore:i," - "amount_ore:i,note:s,vat_code:s,account:s"); + "amount_ore:i,note:s,vat_code:s,account:s,is_text:b"); } #define INVOICE_ROW_COLUMNS \ "line_no,article_no,description,quantity_milli,unit,unit_price_ore," \ - "amount_ore,note,vat_code,account" + "amount_ore,note,vat_code,account,is_text" static yyjson_mut_val *h_invoice_get(struct req *r) { @@ -906,9 +934,13 @@ static yyjson_mut_val *h_invoice_get(struct req *r) "SELECT i.id,i.customer_id,c.name,i.number,i.ocr,i.invoice_date," "i.due_date,i.delivery_date,i.your_ref,i.our_ref,i.notes,i.net_ore," "i.vat_ore,i.total_ore,i.status,i.document_id,i.voucher_id," - "i.last_sent_at,i.last_sent_to,i.created_at,i.created_by" + "i.last_sent_at,i.last_sent_to,i.created_at,i.created_by," + "i.paid_date,i.payment_voucher_id,COALESCE(pv.series,'')," + "COALESCE(pv.number,0)" " FROM invoices i JOIN customers c" " ON c.org_id=i.org_id AND c.id=i.customer_id" + " LEFT JOIN vouchers pv" + " ON pv.org_id=i.org_id AND pv.id=i.payment_voucher_id" " WHERE i.org_id=?1 AND i.id=?2", -1, &st, NULL) != SQLITE_OK) return db_error(r); @@ -971,6 +1003,17 @@ static yyjson_mut_val *h_invoice_get(struct req *r) sq(sqlite3_column_text(st, 19))); yyjson_mut_obj_add_int(r->rdoc, o, "created_by", sqlite3_column_int64(st, 20)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_date", + sq(sqlite3_column_text(st, 21))); + if (sqlite3_column_type(st, 22) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "payment_voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "payment_voucher_id", + sqlite3_column_int64(st, 22)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_voucher_series", + sq(sqlite3_column_text(st, 23))); + yyjson_mut_obj_add_int(r->rdoc, o, "paid_voucher_number", + sqlite3_column_int64(st, 24)); sqlite3_finalize(st); yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); @@ -1006,7 +1049,7 @@ static yyjson_mut_val *h_invoice_list(struct req *r) r->db, "SELECT i.id,i.number,i.ocr,i.customer_id,c.name,i.invoice_date," "i.due_date,i.total_ore,i.status,i.document_id,i.voucher_id," - "i.last_sent_at,i.last_sent_to" + "i.last_sent_at,i.last_sent_to,i.paid_date" " FROM invoices i JOIN customers c" " ON c.org_id=i.org_id AND c.id=i.customer_id" " WHERE i.org_id=?1" @@ -1059,6 +1102,8 @@ static yyjson_mut_val *h_invoice_list(struct req *r) else yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to", sq(sqlite3_column_text(st, 12))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_date", + sq(sqlite3_column_text(st, 13))); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); @@ -1211,6 +1256,10 @@ static yyjson_mut_val *h_invoice_send(struct req *r) fail(r, "SMTP_NOT_CONFIGURED", "smtp_host and smtp_from must be set"); goto done; } + if (!mail_addr_valid(smtp_from)) { + fail(r, "SMTP_NOT_CONFIGURED", "smtp_from must be an email address"); + goto done; + } const char *user = smtp_user && *smtp_user ? smtp_user : ""; if (*user) { if (!smtp_password || !*smtp_password) { @@ -1350,6 +1399,122 @@ static const struct cmd_arg args_invoice_sequence_set[] = { { "next_number", ARG_INT, 1, NULL, NULL, "Next invoice number" }, }; +static const struct cmd_arg args_invoice_pay[] = { + { "id", ARG_INT, 1, NULL, NULL, "Invoice id" }, + { "voucher_id", ARG_INT, 1, NULL, NULL, "Payment voucher id" }, +}; + +/* Marks an invoice paid and links the voucher that settles it. The voucher + must credit the invoice receivable account with the invoice total. */ +static yyjson_mut_val *h_invoice_pay(struct req *r) +{ + int64_t id = 0, voucher_id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + if (!arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) + return fail(r, "INVALID_ARGS", "voucher_id is required"); + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT number,total_ore,status,paid_date FROM invoices" + " 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", "invoice not found"); + } + int64_t number = sqlite3_column_int64(st, 0); + int64_t total = sqlite3_column_int64(st, 1); + char status[16], paid[16]; + snprintf(status, sizeof status, "%s", sq(sqlite3_column_text(st, 2))); + snprintf(paid, sizeof paid, "%s", sq(sqlite3_column_text(st, 3))); + sqlite3_finalize(st); + if (*paid) + return fail(r, "CONFLICT", "invoice is already paid"); + if (strcmp(status, "credited") == 0) + return fail(r, "INVALID_ARGS", "a credited invoice cannot be paid"); + + char receivable[16]; + db_setting_copy(r->db, r->org_id, "invoice_receivable_account", "1510", + receivable, sizeof receivable); + + char date[16], series[16] = ""; + int64_t vnumber = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT date,series,number 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, voucher_id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "voucher not found"); + } + snprintf(date, sizeof date, "%s", sq(sqlite3_column_text(st, 0))); + snprintf(series, sizeof series, "%s", sq(sqlite3_column_text(st, 1))); + vnumber = sqlite3_column_int64(st, 2); + sqlite3_finalize(st); + + if (sqlite3_prepare_v2( + r->db, + "SELECT COALESCE(SUM(r.credit_ore),0) 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 AND a.number=?3", + -1, &st, NULL) != SQLITE_OK) + return db_error(r); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_text(st, 3, receivable, -1, SQLITE_TRANSIENT); + int64_t credited = 0; + if (sqlite3_step(st) == SQLITE_ROW) + credited = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + if (credited != total) + return failf(r, "INVALID_ARGS", + "the payment voucher must credit %s with the invoice" + " total (%lld), not (%lld)", + receivable, (long long)total, (long long)credited); + + if (!r->dry_run) { + if (sqlite3_prepare_v2( + r->db, + "UPDATE invoices SET paid_date=?3,payment_voucher_id=?4" + " WHERE org_id=?1 AND id=?2 AND paid_date=''", + -1, &st, NULL) != SQLITE_OK) + return db_error(r); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + sqlite3_bind_text(st, 3, date, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 4, voucher_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return db_sqlite_error(r); + if (sqlite3_changes(r->db) == 0) + return fail(r, "CONFLICT", "invoice is already paid"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "invoice.pay", 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_int(r->rdoc, o, "number", number); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_date", date); + yyjson_mut_obj_add_int(r->rdoc, o, "payment_voucher_id", voucher_id); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "voucher_series", series); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_number", vnumber); + if (r->dry_run) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; +} + static const struct cmd_arg args_invoice_draft[] = { { "customer_id", ARG_INT, 1, NULL, NULL, "Customer id" }, { "invoice_date", ARG_DATE, 1, NULL, NULL, "Invoice date (YYYY-MM-DD)" }, @@ -1360,7 +1525,7 @@ static const struct cmd_arg args_invoice_draft[] = { { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, { "rows", ARG_JSON, 1, NULL, NULL, "Array of {article_no,description,quantity,unit,unit_price_ore,note," - "vat_code,account}" }, + "vat_code,account,text}; text rows carry only the description" }, }; static const struct cmd_arg args_invoice_get[] = { @@ -1397,6 +1562,8 @@ const struct command g_cmd_invoices[] = { h_invoice_pdf, CMD_ARGS(args_invoice_get) }, { "invoice.send", "E-mail the stored invoice PDF to the customer", PERM_WRITE, 1, 1, 1, h_invoice_send, CMD_ARGS(args_invoice_send) }, + { "invoice.pay", "Mark an invoice paid with a payment voucher", + PERM_WRITE, 1, 1, 1, h_invoice_pay, CMD_ARGS(args_invoice_pay) }, }; const struct cmd_table g_cmd_table_invoices = { diff --git a/src/cmd_payroll.c b/src/cmd_payroll.c index b5db4f1..6dea293 100644 --- a/src/cmd_payroll.c +++ b/src/cmd_payroll.c @@ -491,6 +491,9 @@ static yyjson_mut_val *h_payroll_run_post(struct req *r) char description[64]; snprintf(description, sizeof description, "Lönekörning %s", period); + char series[16]; + db_setting_copy(r->db, r->org_id, "series_payroll", "L", series, + sizeof series); struct ledger_post_opts o; memset(&o, 0, sizeof o); o.org_id = r->org_id; @@ -501,6 +504,7 @@ static yyjson_mut_val *h_payroll_run_post(struct req *r) o.rows = vrows; o.nrows = vn; o.source = "payroll"; + o.series = series; o.dry_run = r->dry_run; o.already_in_tx = 1; struct ledger_error e; @@ -758,6 +762,7 @@ struct payslip_ctx { char employer_org_nr[64]; char employer_phone[64]; char employer_email[256]; + char header_color[8]; char filename[320]; int64_t voucher_id; int64_t employee_id; @@ -920,6 +925,8 @@ static int payslip_prepare(struct req *r, struct payslip_ctx *c) struct payroll_cfg cfg; payroll_cfg_load(r, &cfg); snprintf(c->run_ref, sizeof c->run_ref, "Lönekörning %s", c->period); + db_setting_copy(r->db, r->org_id, "document_header_color", "", + c->header_color, sizeof c->header_color); char safe_name[200]; snprintf(safe_name, sizeof safe_name, "%s", c->employee_name); @@ -938,6 +945,7 @@ static int payslip_prepare(struct req *r, struct payslip_ctx *c) c->d.employer.email = c->employer_email; c->d.employee_name = c->employee_name; c->d.personal_no_masked = c->personal_no; + c->d.header_color = c->header_color; c->d.period = c->period; c->d.pay_date = c->pay_date; c->d.run_ref = c->run_ref; @@ -1341,6 +1349,9 @@ static yyjson_mut_val *h_payroll_pay_tax(struct req *r) if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) return fail(r, "DB_BUSY", "could not start transaction"); in_tx = 1; + char series[16]; + db_setting_copy(r->db, r->org_id, "series_payroll", "L", series, + sizeof series); struct ledger_post_opts o; memset(&o, 0, sizeof o); o.org_id = r->org_id; @@ -1351,6 +1362,7 @@ static yyjson_mut_val *h_payroll_pay_tax(struct req *r) o.rows = rows; o.nrows = vn; o.source = "payroll_tax"; + o.series = series; o.dry_run = r->dry_run; o.already_in_tx = 1; if (ledger_post(r->db, &o, &e, &voucher_json) != 0) { diff --git a/src/cmd_settings.c b/src/cmd_settings.c index 066dc55..5bac3d9 100644 --- a/src/cmd_settings.c +++ b/src/cmd_settings.c @@ -8,6 +8,7 @@ #include "audit.h" #include "config.h" #include "db.h" +#include "mail.h" #include "secret.h" #include "util.h" @@ -20,6 +21,9 @@ static yyjson_mut_val *h_settings_get(struct req *r) yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); int have_default = 0, have_bank = 0, have_receivable = 0, have_revenue = 0; int have_password = 0, have_security = 0; + int have_voucher = 0, have_invoice = 0, have_payroll = 0; + int have_bokslut = 0, have_ib = 0, have_header_color = 0; + char legacy_series[16] = "A"; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st, @@ -35,22 +39,48 @@ static yyjson_mut_val *h_settings_get(struct req *r) } yyjson_mut_obj_add(o, yyjson_mut_strcpy(r->rdoc, k), yyjson_mut_strcpy(r->rdoc, v ? v : "")); - if (strcmp(k, "default_series") == 0) + if (strcmp(k, "default_series") == 0) { have_default = 1; - if (strcmp(k, "bank_account") == 0) + if (v && *v) + snprintf(legacy_series, sizeof legacy_series, "%s", v); + } else if (strcmp(k, "series_voucher") == 0) { + have_voucher = 1; + } else if (strcmp(k, "series_invoice") == 0) { + have_invoice = 1; + } else if (strcmp(k, "series_payroll") == 0) { + have_payroll = 1; + } else if (strcmp(k, "series_bokslut") == 0) { + have_bokslut = 1; + } else if (strcmp(k, "series_ib") == 0) { + have_ib = 1; + } else if (strcmp(k, "bank_account") == 0) { have_bank = 1; - if (strcmp(k, "invoice_receivable_account") == 0) + } else if (strcmp(k, "invoice_receivable_account") == 0) { have_receivable = 1; - if (strcmp(k, "invoice_revenue_account") == 0) + } else if (strcmp(k, "invoice_revenue_account") == 0) { have_revenue = 1; - if (strcmp(k, "smtp_security") == 0) + } else if (strcmp(k, "document_header_color") == 0) { + have_header_color = 1; + } else if (strcmp(k, "smtp_security") == 0) { have_security = 1; + } } } sqlite3_finalize(st); } if (!have_default) yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A"); + if (!have_voucher) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_voucher", + legacy_series); + if (!have_invoice) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_invoice", "F"); + if (!have_payroll) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_payroll", "L"); + if (!have_bokslut) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_bokslut", "Å"); + if (!have_ib) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_ib", "IB"); if (!have_bank) yyjson_mut_obj_add_strcpy(r->rdoc, o, "bank_account", "1930"); if (!have_receivable) @@ -59,6 +89,9 @@ static yyjson_mut_val *h_settings_get(struct req *r) if (!have_revenue) yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_revenue_account", "3001"); + if (!have_header_color) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "document_header_color", + "#314c59"); if (!have_security) yyjson_mut_obj_add_strcpy(r->rdoc, o, "smtp_security", "starttls"); yyjson_mut_obj_add_bool(r->rdoc, o, "smtp_password_set", have_password); @@ -134,8 +167,13 @@ static yyjson_mut_val *h_settings_set(struct req *r) return settings_result(r, key, "[redacted]", 0); } size_t maxlen; - int digits_only = 0, bankgiro = 0, port = 0, security = 0; - if (strcmp(key, "default_series") == 0) + int digits_only = 0, bankgiro = 0, port = 0, security = 0, color = 0; + if (strcmp(key, "default_series") == 0 || + strcmp(key, "series_voucher") == 0 || + strcmp(key, "series_invoice") == 0 || + strcmp(key, "series_payroll") == 0 || + strcmp(key, "series_bokslut") == 0 || + strcmp(key, "series_ib") == 0) maxlen = 8; else if (strcmp(key, "attachment_dir") == 0 || strcmp(key, "smtp_host") == 0 || @@ -160,6 +198,9 @@ static yyjson_mut_val *h_settings_set(struct req *r) bankgiro = 1; } else if (strcmp(key, "invoice_our_ref") == 0) { maxlen = 64; + } else if (strcmp(key, "document_header_color") == 0) { + maxlen = 7; + color = 1; } else return fail(r, "UNSUPPORTED", "unknown setting"); size_t len = strlen(value); @@ -176,6 +217,8 @@ static yyjson_mut_val *h_settings_set(struct req *r) return failf(r, "INVALID_ARGS", "%s must not contain control characters", key); } + if (color && !util_hex_color_valid(value)) + return failf(r, "INVALID_ARGS", "%s must be #rrggbb", key); if (port) { long v = strtol(value, NULL, 10); if (v < 1 || v > 65535) @@ -185,6 +228,10 @@ static yyjson_mut_val *h_settings_set(struct req *r) strcmp(value, "tls") != 0 && strcmp(value, "plain") != 0) return failf(r, "INVALID_ARGS", "%s must be starttls, tls or plain", key); + if ((strcmp(key, "smtp_from") == 0 || + strcmp(key, "smtp_reply_to") == 0) && + !mail_addr_valid(value)) + return failf(r, "INVALID_ARGS", "%s must be an email address", key); if (r->dry_run) return settings_result(r, key, value, 1); sqlite3_stmt *st = NULL; @@ -215,7 +262,8 @@ static yyjson_mut_val *h_settings_set(struct req *r) static const struct cmd_arg args_settings_set[] = { { "key", ARG_STR, 1, NULL, NULL, "default_series, attachment_dir, bank_account," - " invoice_receivable_account, invoice_revenue_account, smtp_host," + " invoice_receivable_account, invoice_revenue_account," + " invoice_bankgiro, invoice_our_ref, document_header_color, smtp_host," " smtp_port, smtp_user, smtp_from, smtp_reply_to, smtp_security or" " smtp_password" }, { "value", ARG_STR, 0, NULL, NULL, @@ -351,6 +351,8 @@ static const char SCHEMA_V1[] = " CHECK (status IN ('issued','credited'))," " document_id INTEGER," " voucher_id INTEGER," + " paid_date TEXT NOT NULL DEFAULT ''," + " payment_voucher_id INTEGER," " last_sent_at TEXT," " last_sent_to TEXT," " created_at TEXT NOT NULL," @@ -359,7 +361,9 @@ static const char SCHEMA_V1[] = " UNIQUE (org_id, number)," " FOREIGN KEY (org_id, customer_id) REFERENCES customers(org_id, id)," " FOREIGN KEY (org_id, document_id) REFERENCES attachments(org_id, id)," - " FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id)" + " FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id)," + " FOREIGN KEY (org_id, payment_voucher_id)" + " REFERENCES vouchers(org_id, id)" ") STRICT;\n" "CREATE TABLE invoice_rows (" @@ -377,6 +381,7 @@ static const char SCHEMA_V1[] = " vat_code TEXT NOT NULL DEFAULT '25'" " CHECK (vat_code IN ('25','12','6','0','rc','eu'))," " account TEXT NOT NULL DEFAULT ''," + " is_text INTEGER NOT NULL DEFAULT 0," " UNIQUE (org_id, id)," " UNIQUE (org_id, invoice_id, line_no)," " FOREIGN KEY (org_id, invoice_id) REFERENCES invoices(org_id, id)" @@ -911,6 +916,49 @@ static int db_upgrade_v11(sqlite3 *db, char **err) err); } +static int db_column_exists(sqlite3 *db, const char *table, const char *col, + char **err) +{ + sqlite3_stmt *st = NULL; + char sql[160]; + snprintf(sql, sizeof sql, + "SELECT count(*) FROM pragma_table_info('%s') WHERE name='%s'", + table, col); + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + set_err(err, "database error"); + return -1; + } + int have = sqlite3_step(st) == SQLITE_ROW && sqlite3_column_int(st, 0) > 0; + sqlite3_finalize(st); + return have; +} + +static int db_add_column(sqlite3 *db, const char *table, const char *col, + const char *decl, char **err) +{ + int have = db_column_exists(db, table, col, err); + if (have != 0) + return have < 0 ? -1 : 0; + char sql[384]; + snprintf(sql, sizeof sql, "ALTER TABLE %s ADD COLUMN %s %s", table, col, + decl); + return db_exec(db, sql, err); +} + +/* v12: invoice text rows (is_text) and the payment link (paid_date, + payment_voucher_id). Fresh databases already carry the columns. */ +static int db_upgrade_v12(sqlite3 *db, char **err) +{ + if (db_add_column(db, "invoice_rows", "is_text", + "INTEGER NOT NULL DEFAULT 0", err) != 0) + return -1; + if (db_add_column(db, "invoices", "paid_date", + "TEXT NOT NULL DEFAULT ''", err) != 0) + return -1; + return db_add_column(db, "invoices", "payment_voucher_id", "INTEGER", + err); +} + static int db_upgrade(sqlite3 *db, int from, char **err) { if (db_exec(db, "BEGIN IMMEDIATE", err) != 0) @@ -955,6 +1003,10 @@ static int db_upgrade(sqlite3 *db, int from, char **err) db_exec(db, "ROLLBACK", NULL); return -1; } + if (from < 12 && db_upgrade_v12(db, err) != 0) { + db_exec(db, "ROLLBACK", NULL); + return -1; + } char *sql = sqlite3_mprintf( "UPDATE meta SET value='%d' WHERE key='schema_version'", BOKF_SCHEMA_VERSION); @@ -1215,6 +1267,14 @@ char *db_setting(sqlite3 *db, int64_t org_id, const char *key) return value; } +void db_setting_copy(sqlite3 *db, int64_t org_id, const char *key, + const char *def, char *out, size_t n) +{ + char *v = db_setting(db, org_id, key); + snprintf(out, n, "%s", v && *v ? v : def); + free(v); +} + char *db_membership_role(sqlite3 *db, int64_t org_id, int64_t user_id) { sqlite3_stmt *st = NULL; @@ -2,9 +2,10 @@ #define BOKF_DB_H #include <sqlite3.h> +#include <stddef.h> #include <stdint.h> -#define BOKF_SCHEMA_VERSION 11 +#define BOKF_SCHEMA_VERSION 12 int db_open(const char *path, sqlite3 **out, char **err); int db_migrate(sqlite3 *db, char **err); @@ -14,6 +15,8 @@ int db_create_user(sqlite3 *db, const char *username, const char *display_name, char **err); char *db_membership_role(sqlite3 *db, int64_t org_id, int64_t user_id); char *db_setting(sqlite3 *db, int64_t org_id, const char *key); +void db_setting_copy(sqlite3 *db, int64_t org_id, const char *key, + const char *def, char *out, size_t n); int64_t db_count(sqlite3 *db, const char *sql); int64_t db_last_id(sqlite3 *db); diff --git a/src/invoice.c b/src/invoice.c index 622c05a..1bdc0da 100644 --- a/src/invoice.c +++ b/src/invoice.c @@ -77,8 +77,12 @@ #define FOOT_CITY_Y 686.0238 #define FOOT_PAGE_Y 819.7954 -#define WORDMARK_X 21.742 +#define HEADER_NAME_X 21.742 #define FAKTURA_X 498.400 +#define HEADER_BASE_Y 69.1358 +#define HEADER_NAME_SIZE 16.0 +#define HEADER_NAME_MIN_SIZE 8.0 +#define HEADER_NAME_MAX_W 460.0 static void text_at(struct pdf *p, double x, double base, const char *font, double size, const char *rgb, const char *s) @@ -430,6 +434,24 @@ static void draw_table(struct pdf *p, const struct invoice_doc *d) const char *s = l->description ? l->description : ""; size_t line = 0; + if (l->is_text) { + for (;;) { + const char *nl = strchr(s, '\n'); + + if (nl) { + put_desc_line(p, y + desc_offset(line), s, + (size_t)(nl - s)); + s = nl + 1; + line++; + } else { + put_desc_line(p, y + desc_offset(line), s, strlen(s)); + break; + } + } + y += (double)(line + 2) * ROW_STEP; + continue; + } + fmt_quantity(l->quantity_milli, qty, sizeof qty); fmt_kronor(l->unit_price_ore, price, sizeof price, 0, 0); fmt_kronor(l->amount_ore, amount, sizeof amount, 0, 0); @@ -563,9 +585,13 @@ int invoice_render_pdf(const struct invoice_doc *d, unsigned char **out, p = pdf_new(); pdf_page(p); pdf_fill_rect(p, PAGE_LEFT, BAR_HEADER_Y, PAGE_RIGHT_X - PAGE_LEFT, - BAR_HEADER_H, INK); - draw_wordmark(p, WORDMARK_MAKANDRA, WORDMARK_X, 69.1358); - draw_wordmark(p, WORDMARK_FAKTURA, FAKTURA_X, 69.1358); + BAR_HEADER_H, + util_hex_color_valid(d->header_color) ? d->header_color + : INK); + pdf_text_fit(p, HEADER_NAME_X, HEADER_BASE_Y, "HB", HEADER_NAME_SIZE, + HEADER_NAME_MIN_SIZE, HEADER_NAME_MAX_W, WHITE, + d->seller.name); + draw_wordmark(p, WORDMARK_FAKTURA, FAKTURA_X, HEADER_BASE_Y); draw_info(p, d); draw_customer(p, &d->customer); draw_table(p, d); diff --git a/src/invoice.h b/src/invoice.h index c92677d..55d1e3b 100644 --- a/src/invoice.h +++ b/src/invoice.h @@ -33,6 +33,7 @@ struct invoice_line { int64_t amount_ore; const char *note; const char *vat_code; + int is_text; }; struct invoice_doc { @@ -40,6 +41,7 @@ struct invoice_doc { struct invoice_customer customer; int64_t number; const char *ocr; + const char *header_color; /* "#rrggbb"; NULL or invalid = default */ const char *invoice_date; const char *due_date; const char *delivery_date; diff --git a/src/ledger.c b/src/ledger.c index a22e15e..b9aad47 100644 --- a/src/ledger.c +++ b/src/ledger.c @@ -360,7 +360,11 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o, char *series_owned = NULL; const char *series = o->series; if (!series || !*series) { - series_owned = db_setting(db, o->org_id, "default_series"); + series_owned = db_setting(db, o->org_id, "series_voucher"); + if (!series_owned || !*series_owned) { + free(series_owned); + 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)) { @@ -38,6 +38,21 @@ static void set_err(char *err, size_t errlen, const char *msg) snprintf(err, errlen, "%s", msg); } +int mail_addr_valid(const char *addr) +{ + if (!addr || !*addr) + return 0; + const char *at = strchr(addr, '@'); + if (!at || at == addr || !at[1] || strchr(at + 1, '@')) + return 0; + for (const char *p = addr; *p; p++) { + unsigned char ch = (unsigned char)*p; + if (ch <= 0x20 || ch == 0x7f || ch == '<' || ch == '>') + return 0; + } + return 1; +} + static int mail_cfg_load(sqlite3 *db, int64_t org_id, struct mail_cfg *c, char *err, size_t errlen) { @@ -54,6 +69,14 @@ static int mail_cfg_load(sqlite3 *db, int64_t org_id, struct mail_cfg *c, set_err(err, errlen, "smtp_host and smtp_from must be set"); goto fail; } + if (!mail_addr_valid(c->from)) { + set_err(err, errlen, "smtp_from must be an email address"); + goto fail; + } + if (c->reply_to && *c->reply_to && !mail_addr_valid(c->reply_to)) { + set_err(err, errlen, "smtp_reply_to must be an email address"); + goto fail; + } if (c->user && *c->user) { if (!c->password || !*c->password) { set_err(err, errlen, @@ -14,6 +14,10 @@ struct mail_message { size_t attach_len; }; +/* True for an address the SMTP client can send to: one @, no spaces or + control characters, not empty. */ +int mail_addr_valid(const char *addr); + /* Checks the org's SMTP settings (including decrypting the stored smtp_password) without sending. 0 configured, -1 not configured. */ int mail_config_check(sqlite3 *db, int64_t org_id, char *err, size_t errlen); diff --git a/src/payslip.c b/src/payslip.c index 81ebded..ee8e008 100644 --- a/src/payslip.c +++ b/src/payslip.c @@ -6,7 +6,6 @@ #include "pdf.h" #include "util.h" -#include "wordmark.h" #define INK "#314c59" #define WHITE "#ffffff" @@ -52,9 +51,12 @@ #define FOOT_PAGE_Y 819.7954 #define FOOT_PAGE_RIGHT 567.4275 -#define WORDMARK_X 21.742 +#define HEADER_NAME_X 21.742 #define TITLE_RIGHT 574.892 #define TITLE_BASE_Y 69.1358 +#define HEADER_NAME_SIZE 16.0 +#define HEADER_NAME_MIN_SIZE 8.0 +#define HEADER_NAME_MAX_W 460.0 static void text_at(struct pdf *p, double x, double base, const char *font, double size, const char *rgb, const char *s) @@ -72,54 +74,6 @@ static void text_right(struct pdf *p, double right, double base, rgb, s); } -/* pdf_path consumes SVG-style y-down paths; wordmark.h stores y-up outline - coordinates, so negate the y of every point before drawing. */ -static void draw_wordmark(struct pdf *p, const char *path, double x, double y) -{ - size_t n = strlen(path); - char *flipped = xmalloc(2 * n + 2); - char op = 0; - int num = 0, in_num = 0, y_coord = 0; - size_t o = 0; - - for (size_t i = 0; i < n; i++) { - char c = path[i]; - - if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) { - if (c == 'M' || c == 'L' || c == 'C') { - op = c; - num = 0; - } else if (c == 'Z') { - op = 0; - } - flipped[o++] = c; - in_num = 0; - continue; - } - if (!in_num && (c == '-' || c == '+' || c == '.' || - (c >= '0' && c <= '9'))) { - in_num = 1; - y_coord = op && (num % 2 == 1); - num++; - if (y_coord) { - if (c == '-') { - i++; - c = path[i]; - } else { - flipped[o++] = '-'; - } - } - } else if (!(c == '-' || c == '+' || c == '.' || - (c >= '0' && c <= '9'))) { - in_num = 0; - } - flipped[o++] = c; - } - flipped[o] = '\0'; - pdf_path(p, flipped, x, y, WHITE); - free(flipped); -} - static void group_digits(char *out, size_t n, uint64_t v) { char digits[24]; @@ -284,8 +238,12 @@ int payslip_render(const struct payslip_data *d, unsigned char **out, p = pdf_new(); pdf_page(p); pdf_fill_rect(p, PAGE_LEFT, BAR_HEADER_Y, PAGE_RIGHT_X - PAGE_LEFT, - BAR_HEADER_H, INK); - draw_wordmark(p, WORDMARK_MAKANDRA, WORDMARK_X, TITLE_BASE_Y); + BAR_HEADER_H, + util_hex_color_valid(d->header_color) ? d->header_color + : INK); + pdf_text_fit(p, HEADER_NAME_X, TITLE_BASE_Y, "HB", HEADER_NAME_SIZE, + HEADER_NAME_MIN_SIZE, HEADER_NAME_MAX_W, WHITE, + d->employer.name); text_right(p, TITLE_RIGHT, TITLE_BASE_Y, "HB", SIZE_TITLE, WHITE, "LÖNEBESKED"); pdf_line(p, PAGE_LEFT, RULE_HEADER_Y, PAGE_RIGHT_X, RULE_HEADER_Y, RULE_W, diff --git a/src/payslip.h b/src/payslip.h index 291b35d..581cd81 100644 --- a/src/payslip.h +++ b/src/payslip.h @@ -16,6 +16,7 @@ struct payslip_data { } employer; const char *employee_name; const char *personal_no_masked; + const char *header_color; /* "#rrggbb"; NULL or invalid = default */ const char *period; const char *pay_date; int tax_table; @@ -353,6 +353,42 @@ double pdf_font_ascent(const char *font, double size) return size > 0 ? size * PDF_ASCENT / 1000.0 : 0.0; } +void pdf_text_fit(struct pdf *p, double x, double baseline, const char *font, + double size, double min_size, double max_w, const char *rgb, + const char *utf8) +{ + char *truncated = NULL; + const char *text = utf8; + double w; + + if (!utf8 || !*utf8 || max_w <= 0) + return; + w = pdf_text_width(font, size, utf8); + if (w > max_w) { + size = size * max_w / w; + if (size < min_size) + size = min_size; + w = pdf_text_width(font, size, utf8); + } + if (w > max_w) { + size_t n = strlen(utf8); + + truncated = xmalloc(n + 4); + memcpy(truncated, utf8, n); + while (n > 0) { + memcpy(truncated + n, "...", 4); + if (pdf_text_width(font, size, truncated) <= max_w) + break; + n--; + while (n > 0 && ((unsigned char)truncated[n] & 0xC0) == 0x80) + n--; + } + text = truncated; + } + pdf_text(p, x, baseline, font, size, rgb, text); + free(truncated); +} + static int parse_num(const char **sp, double *out) { const char *s = *sp; @@ -20,6 +20,12 @@ void pdf_text(struct pdf *p, double x, double baseline, const char *font, double size, const char *rgb, const char *utf8); double pdf_text_width(const char *font, double size, const char *utf8); double pdf_font_ascent(const char *font, double size); +/* Draws utf8 at x/baseline, shrinking the size (down to min_size) to fit + max_w; if it still does not fit, truncates at a whole UTF-8 character so + the text ends with "...". */ +void pdf_text_fit(struct pdf *p, double x, double baseline, const char *font, + double size, double min_size, double max_w, const char *rgb, + const char *utf8); void pdf_path(struct pdf *p, const char *path, double x, double y, const char *rgb); unsigned char *pdf_finish(struct pdf *p, size_t *len); diff --git a/src/reports.c b/src/reports.c index 1d4efdd..981d02c 100644 --- a/src/reports.c +++ b/src/reports.c @@ -4,6 +4,7 @@ #include <stdlib.h> #include <string.h> +#include "db.h" #include "util.h" struct fy_info { @@ -57,27 +58,36 @@ static yyjson_mut_val *fy_json(yyjson_mut_doc *doc, const struct fy_info *fy) return o; } -/* One row per account with IB and period movements. IB is the series "IB" - voucher(s) of this fiscal year plus all non-IB history before `from`. - Amounts signed: debit positive. */ +/* One row per account with IB and period movements; IB as defined by + REPORT_IB_ROW_SQL. skip_closings drops the source system's "Stäng ..." + closing vouchers (SIE-imported only) from the period movements: imported + years close the P&L accounts straight to 2099, so the year otherwise nets + to zero; the TUI årsredovisning uses the same convention. Amounts signed: + debit positive. */ static yyjson_mut_val *balance_query(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, int64_t fy_id, const char *from, const char *to, - const char *types_filter, char **err) + const char *types_filter, + int skip_closings, char **err) { - char sql[1280]; + char ib_series[16]; + db_setting_copy(db, org_id, "series_ib", "IB", ib_series, + sizeof ib_series); + char sql[2048]; snprintf(sql, sizeof sql, "SELECT a.number,a.name,a.type," - " COALESCE(SUM(CASE WHEN v.series <> 'IB'" + " COALESCE(SUM(CASE WHEN v.series <> 'IB' AND v.series <> ?5" + " AND (?6 = 0 OR v.source <> 'sie_import'" + " OR COALESCE(v.description,'') NOT LIKE 'Stäng%%')" " AND v.date BETWEEN ?2 AND ?3 THEN r.debit_ore END),0)," - " COALESCE(SUM(CASE WHEN v.series <> 'IB'" + " COALESCE(SUM(CASE WHEN v.series <> 'IB' AND v.series <> ?5" + " AND (?6 = 0 OR v.source <> 'sie_import'" + " OR COALESCE(v.description,'') NOT LIKE 'Stäng%%')" " AND v.date BETWEEN ?2 AND ?3 THEN r.credit_ore END),0)," - " COALESCE(SUM(CASE WHEN (v.series = 'IB'" - " AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'" - " AND v.date < ?2) THEN r.debit_ore END),0)," - " COALESCE(SUM(CASE WHEN (v.series = 'IB'" - " AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'" - " AND v.date < ?2) THEN r.credit_ore END),0)" + " COALESCE(SUM(CASE WHEN " REPORT_IB_ROW_SQL + " THEN r.debit_ore END),0)," + " COALESCE(SUM(CASE WHEN " REPORT_IB_ROW_SQL + " THEN r.credit_ore END),0)" " FROM accounts a" " LEFT JOIN (voucher_rows r JOIN vouchers v" " ON v.org_id=r.org_id AND v.id=r.voucher_id)" @@ -94,6 +104,8 @@ static yyjson_mut_val *balance_query(yyjson_mut_doc *doc, sqlite3 *db, sqlite3_bind_text(st, 2, from, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, to, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 4, fy_id); + sqlite3_bind_text(st, 5, ib_series, -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 6, skip_closings); yyjson_mut_val *arr = yyjson_mut_arr(doc); while (sqlite3_step(st) == SQLITE_ROW) { const char *number = (const char *)sqlite3_column_text(st, 0); @@ -128,7 +140,8 @@ yyjson_mut_val *report_trial_balance(yyjson_mut_doc *doc, sqlite3 *db, if (!to) to = fy.end; - yyjson_mut_val *rows = balance_query(doc, db, org_id, fy.id, from, to, NULL, err); + yyjson_mut_val *rows = balance_query(doc, db, org_id, fy.id, from, to, NULL, + 0, err); if (!rows) return NULL; @@ -187,7 +200,7 @@ yyjson_mut_val *report_income_statement(yyjson_mut_doc *doc, sqlite3 *db, yyjson_mut_val *rows = balance_query( doc, db, org_id, fy.id, from, to, - " AND a.type IN ('revenue','expense')", err); + " AND a.type IN ('revenue','expense')", 1, err); if (!rows) return NULL; @@ -244,7 +257,7 @@ yyjson_mut_val *report_balance_sheet(yyjson_mut_doc *doc, sqlite3 *db, yyjson_mut_val *rows = balance_query( doc, db, org_id, fy.id, fy.start, to, - " AND a.type IN ('asset','liability','equity')", err); + " AND a.type IN ('asset','liability','equity')", 0, err); if (!rows) return NULL; @@ -280,10 +293,11 @@ yyjson_mut_val *report_balance_sheet(yyjson_mut_doc *doc, sqlite3 *db, yyjson_mut_arr_add_val(*target, copy); } - /* current year result belongs to equity */ + /* current year result belongs to equity; the closings stay included so a + transferred result (2099) does not get counted twice */ yyjson_mut_val *inc = balance_query( doc, db, org_id, fy.id, fy.start, to, - " AND a.type IN ('revenue','expense')", err); + " AND a.type IN ('revenue','expense')", 0, err); int64_t result = 0; if (inc) { size_t m = yyjson_mut_arr_size(inc); @@ -365,6 +379,18 @@ static int vat_box_payable(const char *box) #define VAT_MAX_BOXES 64 +/* Vouchers left out of the momsdeklaration: the momsomföring itself (any + voucher with a 2650 row moves the period's VAT to the redovisningskonto + and would zero the boxes) and a source system's imported "Stäng ..." year + closings, which zero the P&L underlag. */ +#define VAT_VOUCHER_SQL \ + " AND NOT EXISTS (SELECT 1 FROM voucher_rows r2 JOIN accounts a2" \ + " ON a2.org_id = r2.org_id AND a2.id = r2.account_id" \ + " WHERE r2.org_id = v.org_id AND r2.voucher_id = v.id" \ + " AND a2.number = '2650')" \ + " AND NOT (v.source = 'sie_import'" \ + " AND COALESCE(v.description,'') LIKE 'Stäng%')" + yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, const char *from, const char *to, char **err) { @@ -377,6 +403,9 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, int64_t amount; } acc[VAT_MAX_BOXES]; size_t nacc = 0; + char ib_series[16]; + db_setting_copy(db, org_id, "series_ib", "IB", ib_series, + sizeof ib_series); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( db, @@ -401,21 +430,25 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, " JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND a.number=?2 AND v.series <> 'IB'" - " AND v.date BETWEEN ?3 AND ?4"; + " AND v.series <> ?5 AND v.date BETWEEN ?3 AND ?4" + VAT_VOUCHER_SQL; else if (strcmp(mt, "type") == 0) sql = "SELECT COALESCE(SUM(r.debit_ore),0)," "COALESCE(SUM(r.credit_ore),0) FROM voucher_rows r" " JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND a.type=?2 AND v.series <> 'IB'" - " AND v.date BETWEEN ?3 AND ?4"; + " AND v.series <> ?5 AND v.date BETWEEN ?3 AND ?4" + VAT_VOUCHER_SQL; else sql = "SELECT COALESCE(SUM(r.debit_ore),0)," "COALESCE(SUM(r.credit_ore),0) FROM voucher_rows r" " JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND CAST(a.number AS INTEGER) BETWEEN ?2" - " AND ?3 AND v.series <> 'IB' AND v.date BETWEEN ?4 AND ?5"; + " AND ?3 AND v.series <> 'IB' AND v.series <> ?6" + " AND v.date BETWEEN ?4 AND ?5" + VAT_VOUCHER_SQL; if (sqlite3_prepare_v2(db, sql, -1, &qs, NULL) != SQLITE_OK) break; sqlite3_bind_int64(qs, 1, org_id); @@ -423,6 +456,7 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, sqlite3_bind_text(qs, 2, pattern, -1, SQLITE_TRANSIENT); sqlite3_bind_text(qs, 3, from, -1, SQLITE_TRANSIENT); sqlite3_bind_text(qs, 4, to, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(qs, 5, ib_series, -1, SQLITE_TRANSIENT); } else { long lo = 0, hi = 0; if (sscanf(pattern, "%ld-%ld", &lo, &hi) != 2) { @@ -433,6 +467,7 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, sqlite3_bind_int64(qs, 3, hi); sqlite3_bind_text(qs, 4, from, -1, SQLITE_TRANSIENT); sqlite3_bind_text(qs, 5, to, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(qs, 6, ib_series, -1, SQLITE_TRANSIENT); } if (sqlite3_step(qs) == SQLITE_ROW) amount = (sqlite3_column_int64(qs, 0) - @@ -532,7 +567,7 @@ yyjson_mut_val *report_general_ledger(yyjson_mut_doc *doc, sqlite3 *db, if (!to) to = fy.end; yyjson_mut_val *bal = - balance_query(doc, db, org_id, fy.id, from, to, NULL, err); + balance_query(doc, db, org_id, fy.id, from, to, NULL, 0, err); if (!bal) return NULL; sqlite3_stmt *rs = NULL; diff --git a/src/reports.h b/src/reports.h index c4a6e4e..82c29e5 100644 --- a/src/reports.h +++ b/src/reports.h @@ -6,6 +6,21 @@ #include "yyjson.h" +/* SQL condition: voucher row `r` of voucher `v` on account `a` belongs to the + opening balance of fiscal year ?4 (org ?1) for a period starting ?2; ?5 is + the configured IB series. Balance accounts carry all earlier history, + including earlier years' IB vouchers; P&L accounts restart at the year + start, so only this year's IB vouchers and movements before ?2 count. */ +#define REPORT_IB_ROW_SQL \ + "((v.series = 'IB' OR v.series = ?5) AND (v.fiscal_year_id = ?4" \ + " OR (v.date < (SELECT f.start_date FROM fiscal_years f" \ + " WHERE f.org_id = ?1 AND f.id = ?4)" \ + " AND a.type NOT IN ('revenue','expense'))))" \ + " OR (v.series <> 'IB' AND v.series <> ?5 AND v.date < ?2" \ + " AND (a.type NOT IN ('revenue','expense') OR v.date >=" \ + " (SELECT f.start_date FROM fiscal_years f" \ + " WHERE f.org_id = ?1 AND f.id = ?4)))" + yyjson_mut_val *report_trial_balance(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, int64_t fy_id, const char *from, const char *to, @@ -8,8 +8,10 @@ #include <string.h> #include <time.h> +#include "db.h" #include "ledger.h" #include "log.h" +#include "reports.h" #include "util.h" #include "version.h" @@ -135,20 +137,21 @@ int sie_export_file(sqlite3 *db, int64_t org_id, int64_t fy_id, put_date(f, end); put(f, "\n"); - /* accounts and balances: IB = this year's series "IB" voucher(s) plus - all non-IB history before the year; UB = everything through the end; - RES = the year's non-IB movements. */ + /* accounts and balances: IB per REPORT_IB_ROW_SQL, UB = IB plus the + year's non-IB movements, RES = those movements. #IB/#UB are written + for balance accounts only, #RES for P&L accounts only. */ + char ib_series[16]; + db_setting_copy(db, org_id, "series_ib", "IB", ib_series, + sizeof ib_series); if (sqlite3_prepare_v2( db, "SELECT a.number,a.name," - " COALESCE(SUM(CASE WHEN (v.series = 'IB'" - " AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'" - " AND v.date < ?2) THEN r.debit_ore - r.credit_ore END),0)," - " COALESCE(SUM(CASE WHEN v.date <= ?3" + " COALESCE(SUM(CASE WHEN " REPORT_IB_ROW_SQL " THEN r.debit_ore - r.credit_ore END),0)," - " COALESCE(SUM(CASE WHEN v.series <> 'IB'" + " COALESCE(SUM(CASE WHEN v.series <> 'IB' AND v.series <> ?5" " AND v.date >= ?2 AND v.date <= ?3" - " THEN r.debit_ore - r.credit_ore END),0)" + " THEN r.debit_ore - r.credit_ore END),0)," + " a.type IN ('revenue','expense')" " FROM accounts a LEFT JOIN (voucher_rows r JOIN vouchers v" " ON v.org_id=r.org_id AND v.id=r.voucher_id)" " ON r.org_id=a.org_id AND r.account_id=a.id" @@ -163,12 +166,18 @@ int sie_export_file(sqlite3 *db, int64_t org_id, int64_t fy_id, sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, end, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 4, fy_id); + sqlite3_bind_text(st, 5, ib_series, -1, SQLITE_TRANSIENT); while (sqlite3_step(st) == SQLITE_ROW) { const char *number = (const char *)sqlite3_column_text(st, 0); const char *name = (const char *)sqlite3_column_text(st, 1); int64_t ib = sqlite3_column_int64(st, 2); - int64_t ub = sqlite3_column_int64(st, 3); - int64_t res = sqlite3_column_int64(st, 4); + int64_t res = sqlite3_column_int64(st, 3); + int pl = sqlite3_column_int(st, 4); + int64_t ub = ib + res; + if (pl) + ib = ub = 0; + else + res = 0; put(f, "#KONTO "); put(f, number); put(f, " "); @@ -276,6 +276,19 @@ char *util_str_trim(char *s) return s; } +int util_hex_color_valid(const char *s) +{ + if (!s || s[0] != '#' || strlen(s) != 7) + return 0; + for (int i = 1; i < 7; i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'))) + return 0; + } + return 1; +} + static int is_leap(int y) { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; @@ -32,6 +32,7 @@ int util_const_eq(const void *a, const void *b, size_t n); int64_t util_now(void); void util_iso8601(int64_t t, char *buf, size_t n); char *util_str_trim(char *s); +int util_hex_color_valid(const char *s); int util_parse_iso_date(const char *s); /* base64 (standard alphabet, padding required) */ diff --git a/tests/invoice_check.c b/tests/invoice_check.c index 46b1621..e61a41d 100644 --- a/tests/invoice_check.c +++ b/tests/invoice_check.c @@ -29,8 +29,8 @@ static void eq64(const char *what, int64_t got, int64_t want) static const struct invoice_line synthetic_lines[] = { { "", "Utvecklingsarbete\nPeriod 2026-01-01 tom 2026-01-31", 61000, "tim", - 120000, 7320000, "", "25" }, - { "A-1", "Konsult", 12500, "tim", 80000, 1000000, "Omvänd moms", "rc" }, + 120000, 7320000, "", "25", 0 }, + { "A-1", "Konsult", 12500, "tim", 80000, 1000000, "Omvänd moms", "rc", 0 }, }; static void make_doc(struct invoice_doc *d) @@ -65,12 +65,12 @@ static void make_doc(struct invoice_doc *d) static void test_totals(void) { static const struct invoice_line lines[] = { - { "", "a", 1000, "st", 1000, 100000, "", "25" }, - { "", "b", 1000, "st", 1000, 50000, "", "12" }, - { "", "c", 1000, "st", 1000, 10000, "", "6" }, - { "", "d", 1000, "st", 1000, 25000, "", "rc" }, - { "", "e", 1000, "st", 1000, 15000, "", "0" }, - { "", "f", 1000, "st", 1000, 4000, "", "eu" }, + { "", "a", 1000, "st", 1000, 100000, "", "25", 0 }, + { "", "b", 1000, "st", 1000, 50000, "", "12", 0 }, + { "", "c", 1000, "st", 1000, 10000, "", "6", 0 }, + { "", "d", 1000, "st", 1000, 25000, "", "rc", 0 }, + { "", "e", 1000, "st", 1000, 15000, "", "0", 0 }, + { "", "f", 1000, "st", 1000, 4000, "", "eu", 0 }, }; struct invoice_doc d; struct invoice_totals t; @@ -89,7 +89,7 @@ static void test_totals(void) { static const struct invoice_line half[] = { - { "", "half", 1000, "st", 100, 50, "", "25" }, + { "", "half", 1000, "st", 100, 50, "", "25", 0 }, }; d.lines = half; @@ -129,6 +129,19 @@ static int contains(const unsigned char *data, size_t len, const char *needle) return 0; } +static int count_of(const unsigned char *data, size_t len, const char *needle) +{ + size_t n = strlen(needle); + int count = 0; + + if (!n) + return 0; + for (size_t i = 0; i + n <= len; i++) + if (memcmp(data + i, needle, n) == 0) + count++; + return count; +} + static void test_render(const char *path) { struct invoice_doc d; @@ -147,6 +160,8 @@ static void test_render(const char *path) if (data) { check(memcmp(data, "%PDF-1.4", 8) == 0, "render header"); check(contains(data, len, "Testleverant"), "render seller text"); + check(count_of(data, len, "Testleverant") == 2, + "render seller name in header and footer"); check(contains(data, len, "Testkund AB"), "render customer text"); check(contains(data, len, "Summa att betala SEK"), "render summary"); check(contains(data, len, "Test Person"), "render our ref"); @@ -166,6 +181,45 @@ static void test_render(const char *path) free(data); { + unsigned char *colored = NULL; + size_t colored_len = 0; + + d.header_color = "#ff0000"; + check(invoice_render_pdf(&d, &colored, &colored_len) == 0, + "render custom colour returns 0"); + if (colored) + check(contains(colored, colored_len, "1 0 0 rg"), + "render custom header colour"); + free(colored); + + colored = NULL; + d.header_color = "not-a-colour"; + check(invoice_render_pdf(&d, &colored, &colored_len) == 0, + "render invalid colour returns 0"); + if (colored) + check(!contains(colored, colored_len, "1 0 0 rg"), + "render invalid colour falls back"); + free(colored); + d.header_color = NULL; + } + { + static char long_name[240]; + unsigned char *long_pdf = NULL; + size_t long_len = 0; + + memset(long_name, 'A', sizeof long_name - 1); + long_name[sizeof long_name - 1] = '\0'; + d.seller.name = long_name; + check(invoice_render_pdf(&d, &long_pdf, &long_len) == 0, + "render long name returns 0"); + if (long_pdf) + check(contains(long_pdf, long_len, "..."), + "render long name is truncated"); + free(long_pdf); + d.seller.name = "Testleverantör AB"; + } + + { unsigned char *bad = NULL; size_t n = 0; diff --git a/tests/pdf_check.c b/tests/pdf_check.c index 4d56cab..4eaf24d 100644 --- a/tests/pdf_check.c +++ b/tests/pdf_check.c @@ -82,6 +82,12 @@ int main(int argc, char **argv) pdf_line(p, 0, 80, 595, 80, 0.5, "#314c59"); pdf_line(p, 20, 100, 575, 100, 0.25, "#314c59"); + pdf_text_fit(p, 20, 220, "HB", 16, 8, 200, "#314c59", "Fitted"); + pdf_text_fit(p, 20, 240, "H", 10, 8, 40, "#314c59", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + pdf_text_fit(p, 20, 260, "H", 10, 8, 40, "#314c59", NULL); + pdf_text_fit(p, 20, 260, "H", 10, 8, 40, "#314c59", ""); + put_text(p, "MAKANDRA", 20, 70, "HB", 16, "#ffffff"); put_text(p, "FAKTURA", 575 - pdf_text_width("H", 16, "FAKTURA"), 70, "H", 16, "#ffffff"); @@ -123,6 +129,9 @@ int main(int argc, char **argv) check(contains(data, len, "1 0 obj"), "object 1"); check(contains(data, len, "6 0 obj"), "object 6"); check(!contains(data, len, "\r"), "no CR"); + check(contains(data, len, "Fitted"), "text_fit keeps short text"); + check(contains(data, len, "(AAAAAA...)"), + "text_fit truncates long text"); if (argc > 1) { FILE *f = fopen(argv[1], "wb"); diff --git a/tests/test_core.c b/tests/test_core.c index 12d42e5..54e73f5 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -40,6 +40,18 @@ static void mark_failed(void) g_failed[g_failed_n++] = g_test; } +static int count_of(const char *text, const char *needle) +{ + size_t n = strlen(needle); + int count = 0; + + if (!n) + return 0; + for (const char *p = text; (p = strstr(p, needle)) != NULL; p++) + count++; + return count; +} + struct tctx { char tmpdir[512]; char backupdir[512]; @@ -1549,6 +1561,116 @@ static void test_reports(struct tctx *t) yyjson_doc_free(d); } +static void test_imported_closings(struct tctx *t) +{ + yyjson_doc *d; + int64_t org = 0; + + (void)t; + d = call(reqf("{\"v\":1,\"id\":\"ic1\",\"cmd\":\"org.create\"," + "\"session\":\"%s\",\"args\":{\"name\":\"Import AB\"}}", + g_session)); + CHECK_OK(d); + org = jint(d, "result.id"); + CHECK(org > 0); + yyjson_doc_free(d); + + /* The source system closes the P&L accounts straight to 2099; the income + statement must still show the year's real figures. The file is PC8 + (CP437) like real exports: \x84 = ä, \x94 = ö. */ + static const char sie[] = + "#FLAGGA 0\n" + "#FORMAT PC8\n" + "#SIETYP 4\n" + "#FNAMN \"Import AB\"\n" + "#RAR 0 20260101 20261231\n" + "#VER \"V\" \"1\" 20260201 \"F\x94rs\x84" "ljning\"\n" + "{\n#TRANS 1930 {} 1250.00\n#TRANS 3001 {} -1000.00\n" + "#TRANS 2611 {} -250.00\n}\n" + "#VER \"V\" \"2\" 20260202 \"Ink\x94" "p\"\n" + "{\n#TRANS 5410 {} 200.00\n#TRANS 2640 {} 50.00\n" + "#TRANS 1930 {} -250.00\n}\n" + "#VER \"V\" \"3\" 20260430 \"St\x84" "ng intäktskonton\"\n" + "{\n#TRANS 3001 {} 1000.00\n#TRANS 2099 {} -1000.00\n}\n" + "#VER \"V\" \"4\" 20260430 \"St\x84" "ng kostnadskonton\"\n" + "{\n#TRANS 2099 {} 200.00\n#TRANS 5410 {} -200.00\n}\n"; + char *sie_b64 = util_b64((const unsigned char *)sie, strlen(sie)); + d = call_sie_import(g_session, org, sie_b64, 0); + free(sie_b64); + CHECK_OK(d); + CHECK(jint(d, "result.vouchers") == 4); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ic6\",\"cmd\":\"report.income_statement\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)org)); + CHECK_OK(d); + CHECK(jint(d, "result.result_ore") == 80000); + CHECK(find_amount(d, "result.revenue.accounts", "account", "3001", + "amount_ore") == 100000); + CHECK(find_amount(d, "result.expenses.accounts", "account", "5410", + "amount_ore") == 20000); + yyjson_doc_free(d); + + /* The result sits in 2099, so the balance sheet must not count it twice. */ + d = call(reqf("{\"v\":1,\"id\":\"ic7\",\"cmd\":\"report.balance_sheet\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)org)); + CHECK_OK(d); + /* 2640 is chart-typed as a liability (debit balance shows negative) */ + CHECK(jint(d, "result.assets.total_ore") == 100000); + CHECK(jint(d, "result.liabilities.total_ore") == 20000); + CHECK(jint(d, "result.equity.total_ore") == 80000); + CHECK(jint(d, "result.assets.total_ore") == + jint(d, "result.liabilities.total_ore") + + jint(d, "result.equity.total_ore")); + CHECK(find_amount(d, "result.equity.accounts", "account", "2099", + "amount_ore") == 80000); + yyjson_doc_free(d); + + /* the declaration path: the SRU result must not be zeroed either */ + d = call(reqf("{\"v\":1,\"id\":\"ic8\",\"cmd\":\"org.update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"org_nr\":\"559331-2126\",\"postal_code\":\"192 48\"," + "\"city\":\"Sollentuna\"}}", + g_session, (int)org)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ic9\",\"cmd\":\"sru.export\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{}}", + g_session, (int)org)); + CHECK_OK(d); + const char *sb64 = jstr(d, "result.blanketter.content_base64"); + unsigned char *srub = NULL; + size_t srul = 0; + CHECK(sb64 && util_b64_decode(sb64, strlen(sb64), &srub, &srul) == 0); + char *sru = xmalloc(srul + 1); + memcpy(sru, srub, srul); + sru[srul] = '\0'; + free(srub); + CHECK(strstr(sru, "#UPPGIFT 7450 800") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7650 800") != NULL); + free(sru); + yyjson_doc_free(d); + + /* a voucher entered in bokf is never skipped, whatever its text */ + d = call(reqf("{\"v\":1,\"id\":\"ic10\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-05-02\",\"description\":\"Stängsel\",\"rows\":[" + "{\"account\":\"1930\",\"debit_ore\":100}," + "{\"account\":\"3001\",\"credit_ore\":100}]}}", + g_session, (int)org)); + CHECK_OK(d); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ic11\",\"cmd\":\"report.income_statement\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)org)); + CHECK_OK(d); + CHECK(jint(d, "result.result_ore") == 80100); + yyjson_doc_free(d); +} + static void test_general_ledger(struct tctx *t) { yyjson_doc *d; @@ -2055,6 +2177,126 @@ static void test_ib(struct tctx *t) yyjson_doc_free(d); } +/* Opening balances carry earlier years' IB vouchers; P&L accounts restart + at every year start (reports and SIE export). */ +static void test_ib_carry(struct tctx *t) +{ + (void)t; + yyjson_doc *d; + + d = call(reqf("{\"v\":1,\"id\":\"ibc1\",\"cmd\":\"org.create\"," + "\"session\":\"%s\",\"args\":{\"name\":\"Överföring AB\"}}", + g_session)); + CHECK_OK(d); + int org = (int)jint(d, "result.id"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ibc2\",\"cmd\":\"fiscal_year.list\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, org)); + CHECK_OK(d); + char start[11] = "", end[11] = ""; + snprintf(start, sizeof start, "%s", jstr(d, "result.items.0.start_date")); + snprintf(end, sizeof end, "%s", jstr(d, "result.items.0.end_date")); + yyjson_doc_free(d); + int y = atoi(end) + 1; + + const char *posts[][4] = { + { start, "IB", "1940", "2010" }, + { start, "V", "1930", "3001" }, + { end, "V", "8999", "2099" }, + }; + const int64_t amounts[] = { 2500000, 100000, 100000 }; + for (size_t i = 0; i < 3; i++) { + d = call(reqf("{\"v\":1,\"id\":\"ibc3\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"%s\",\"description\":\"År 1\",\"series\":\"%s\"," + "\"rows\":[{\"account\":\"%s\",\"debit_ore\":%lld}," + "{\"account\":\"%s\",\"credit_ore\":%lld}]}}", + g_session, org, posts[i][0], posts[i][1], posts[i][2], + (long long)amounts[i], posts[i][3], + (long long)amounts[i])); + CHECK_OK(d); + yyjson_doc_free(d); + } + + d = call(reqf("{\"v\":1,\"id\":\"ibc4\",\"cmd\":\"fiscal_year.open\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"label\":\"%d\"," + "\"start_date\":\"%d-01-01\",\"end_date\":\"%d-12-31\"}}", + g_session, org, y, y, y)); + CHECK_OK(d); + int64_t fy2 = jint(d, "result.id"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ibc5\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"%d-02-01\",\"description\":\"År 2\",\"rows\":" + "[{\"account\":\"1930\",\"debit_ore\":5000}," + "{\"account\":\"3001\",\"credit_ore\":5000}]}}", + g_session, org, y)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ibc6\",\"cmd\":\"report.trial_balance\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":" + "%lld}}", + g_session, org, (long long)fy2)); + CHECK_OK(d); + CHECK(find_amount(d, "result.accounts", "account", "1940", "ib_ore") == + 2500000); + CHECK(find_amount(d, "result.accounts", "account", "2010", "ib_ore") == + -2500000); + CHECK(find_amount(d, "result.accounts", "account", "2099", "ib_ore") == + -100000); + CHECK(find_amount(d, "result.accounts", "account", "3001", "ib_ore") == 0); + CHECK(find_amount(d, "result.accounts", "account", "8999", "ib_ore") == -1); + CHECK(jint(d, "result.totals.ib_ore") == 0); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ibc7\",\"cmd\":\"report.trial_balance\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":" + "%lld,\"from\":\"%d-03-01\"}}", + g_session, org, (long long)fy2, y)); + CHECK_OK(d); + CHECK(find_amount(d, "result.accounts", "account", "3001", "ib_ore") == + -5000); + CHECK(find_amount(d, "result.accounts", "account", "1930", "ib_ore") == + 105000); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ibc8\",\"cmd\":\"report.balance_sheet\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":" + "%lld}}", + g_session, org, (long long)fy2)); + CHECK_OK(d); + CHECK(find_amount(d, "result.equity.accounts", "account", "2010", + "amount_ore") == 2500000); + CHECK(jint(d, "result.assets.total_ore") == 2605000); + CHECK(jint(d, "result.assets.total_ore") == + jint(d, "result.liabilities.total_ore") + + jint(d, "result.equity.total_ore")); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ibc9\",\"cmd\":\"sie.export\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":" + "%lld}}", + g_session, org, (long long)fy2)); + CHECK_OK(d); + FILE *sf = fopen(jstr(d, "result.path"), "rb"); + yyjson_doc_free(d); + CHECK(sf != NULL); + if (sf) { + char sbuf[65536]; + size_t sn = fread(sbuf, 1, sizeof sbuf - 1, sf); + sbuf[sn] = '\0'; + fclose(sf); + CHECK(strstr(sbuf, "#IB 0 2010 -25000.00") != NULL); + CHECK(strstr(sbuf, "#UB 0 1930 1050.00") != NULL); + CHECK(strstr(sbuf, "#RES 0 3001 -50.00") != NULL); + CHECK(strstr(sbuf, "#IB 0 3001") == NULL); + CHECK(strstr(sbuf, "#UB 0 3001") == NULL); + CHECK(strstr(sbuf, "#RES 0 1930") == NULL); + } +} + static void test_period_lock(struct tctx *t) { yyjson_doc *d; @@ -2315,6 +2557,197 @@ static void test_settings(struct tctx *t) CHECK_OK(d); CHECK_STR(d, "result.attachment_dir", "/tmp/bilagor"); yyjson_doc_free(d); + + /* smtp addresses are checked when they are saved */ + d = call(reqf("{\"v\":1,\"id\":\"smtpaddr1\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"smtp_from\",\"value\":\"Anders Bergsten\"}}", + g_session, (int)t->org_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"smtpaddr2\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"smtp_reply_to\",\"value\":\"inte en adress\"}}", + g_session, (int)t->org_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"smtpaddr3\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"smtp_from\",\"value\":\"anders@example.com\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + CHECK_STR(d, "result.value", "anders@example.com"); + yyjson_doc_free(d); + + /* the invoice header colour must be #rrggbb */ + d = call(reqf("{\"v\":1,\"id\":\"hdr0\",\"cmd\":\"settings.get\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)t->org_id)); + CHECK_OK(d); + CHECK_STR(d, "result.document_header_color", "#314c59"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"hdr1\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"document_header_color\",\"value\":\"#1a2b3c\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + CHECK_STR(d, "result.value", "#1a2b3c"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"hdr2\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"document_header_color\",\"value\":\"1a2b3c\"}}", + g_session, (int)t->org_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"hdr3\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"document_header_color\",\"value\":\"#12345\"}}", + g_session, (int)t->org_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"hdr4\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"document_header_color\",\"value\":\"#1234567\"}}", + g_session, (int)t->org_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"hdr5\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"document_header_color\",\"value\":\"#GGGGGG\"}}", + g_session, (int)t->org_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); +} + +static void test_series(struct tctx *t) +{ + yyjson_doc *d; + (void)t; + + /* own org: the series settings and their vouchers must not disturb the + shared test org's counts and defaults */ + d = call(reqf("{\"v\":1,\"id\":\"sv0\",\"cmd\":\"org.create\"," + "\"session\":\"%s\",\"args\":{\"name\":\"Serie AB\"," + "\"org_nr\":\"5560001111\"}}", + g_session)); + CHECK_OK(d); + int org = (int)jint(d, "result.id"); + CHECK(org > 0); + yyjson_doc_free(d); + + /* the legacy default_series seeds series_voucher; the other features + have their own conventional defaults */ + d = call(reqf("{\"v\":1,\"id\":\"sv1a\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"default_series\",\"value\":\"V-\"}}", + g_session, org)); + CHECK_OK(d); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"sv1\",\"cmd\":\"settings.get\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, org)); + CHECK_OK(d); + CHECK_STR(d, "result.series_voucher", "V-"); + CHECK_STR(d, "result.series_invoice", "F"); + CHECK_STR(d, "result.series_payroll", "L"); + CHECK_STR(d, "result.series_bokslut", "Å"); + CHECK_STR(d, "result.series_ib", "IB"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"sv2\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"series_foo\",\"value\":\"X\"}}", + g_session, org)); + CHECK_STR(d, "error.code", "UNSUPPORTED"); + yyjson_doc_free(d); + + /* an explicit per-feature voucher series wins over the legacy key */ + d = call(reqf("{\"v\":1,\"id\":\"sv3\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"series_voucher\",\"value\":\"S-\"}}", + g_session, org)); + CHECK_OK(d); + CHECK_STR(d, "result.value", "S-"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"sv4\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-09-20\",\"description\":\"Serieserie\",\"rows\":" + "[{\"account\":\"1930\",\"debit_ore\":100},{\"account\":" + "\"3001\",\"credit_ore\":100}]}}", + g_session, org)); + CHECK_OK(d); + CHECK_STR(d, "result.series", "S-"); + yyjson_doc_free(d); + + /* invoices post in the invoice series */ + d = call(reqf("{\"v\":1,\"id\":\"sv5\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"series_invoice\",\"value\":\"F2\"}}", + g_session, org)); + CHECK_OK(d); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"sv6\",\"cmd\":\"customer.create\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":" + "\"Serie Test AB\"}}", + g_session, org)); + CHECK_OK(d); + int64_t cust = jint(d, "result.id"); + CHECK(cust > 0); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"sv7\",\"cmd\":\"invoice.issue\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":" + "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":" + "\"2026-10-20\",\"rows\":[{\"description\":\"Konsult\"," + "\"quantity\":\"1\",\"unit_price_ore\":100000," + "\"vat_code\":\"25\"}]}}", + g_session, org, (long long)cust)); + CHECK_OK(d); + int64_t voucher = jint(d, "result.voucher_id"); + CHECK(voucher > 0); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"sv8\",\"cmd\":\"voucher.get\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, org, (long long)voucher)); + CHECK_OK(d); + CHECK_STR(d, "result.series", "F2"); + yyjson_doc_free(d); + + /* the configured IB series counts as ingående balans */ + d = call(reqf("{\"v\":1,\"id\":\"sv9\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"series_ib\",\"value\":\"IBX\"}}", + g_session, org)); + CHECK_OK(d); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"sv10\",\"cmd\":\"report.trial_balance\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, org)); + CHECK_OK(d); + int64_t ib_before = find_amount(d, "result.accounts", "account", "1930", + "ib_ore"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"sv11\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-01-01\",\"description\":\"IB serietest\"," + "\"series\":\"IBX\",\"rows\":[{\"account\":\"1930\"," + "\"debit_ore\":700},{\"account\":\"2010\"," + "\"credit_ore\":700}]}}", + g_session, org)); + CHECK_OK(d); + CHECK_STR(d, "result.series", "IBX"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"sv12\",\"cmd\":\"report.trial_balance\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, org)); + CHECK_OK(d); + CHECK(find_amount(d, "result.accounts", "account", "1930", "ib_ore") == + ib_before + 700); + yyjson_doc_free(d); } static void test_smtp_settings(struct tctx *t) @@ -2385,7 +2818,7 @@ static void test_smtp_settings(struct tctx *t) d = call(reqf("{\"v\":1,\"id\":\"110\",\"cmd\":\"settings.set\"," "\"session\":\"%s\",\"org\":%d,\"args\":" - "{\"key\":\"smtp_from\",\"value\":\"Bokf AB <a@b.se>\"}}", + "{\"key\":\"smtp_from\",\"value\":\"faktura@example.se\"}}", g_session, (int)t->org_id)); CHECK_OK(d); yyjson_doc_free(d); @@ -2412,7 +2845,7 @@ static void test_smtp_settings(struct tctx *t) CHECK_STR(d, "result.smtp_host", "smtp.example.se"); CHECK_STR(d, "result.smtp_port", "587"); CHECK_STR(d, "result.smtp_user", "faktura@example.se"); - CHECK_STR(d, "result.smtp_from", "Bokf AB <a@b.se>"); + CHECK_STR(d, "result.smtp_from", "faktura@example.se"); CHECK_STR(d, "result.smtp_reply_to", "svar@example.se"); CHECK_STR(d, "result.smtp_security", "tls"); yyjson_doc_free(d); @@ -3169,6 +3602,41 @@ static void test_invoices(struct tctx *t) CHECK(stored && preview_b64 && strcmp(stored, preview_b64) == 0); yyjson_doc_free(d); + /* the invoice header colour setting reaches the rendered document */ + d = call(reqf("{\"v\":1,\"id\":\"354a\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"document_header_color\",\"value\":\"#ff0000\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"354b\",\"cmd\":\"invoice.preview\"," + "\"session\":\"%s\",\"org\":%d,\"args\":%s}", + g_session, (int)t->org_id, draft_args)); + CHECK_OK(d); + const char *red_b64 = jstr(d, "result.content_base64"); + unsigned char *red_pdf = NULL; + size_t red_len = 0; + CHECK(red_b64 && + util_b64_decode(red_b64, strlen(red_b64), &red_pdf, &red_len) == 0); + if (red_pdf) { + char *red_text = xmalloc(red_len + 1); + + memcpy(red_text, red_pdf, red_len); + red_text[red_len] = '\0'; + CHECK(strstr(red_text, "1 0 0 rg") != NULL); + free(red_text); + } + free(red_pdf); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"354c\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"document_header_color\",\"value\":\"#314c59\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"355\",\"cmd\":\"invoice.issue\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":" "%lld,\"invoice_date\":\"2026-09-21\",\"due_date\":" @@ -3279,6 +3747,120 @@ static void test_invoices(struct tctx *t) free(preview_b64); } +static void test_invoice_extras(struct tctx *t) +{ + yyjson_doc *d; + + d = call(reqf("{\"v\":1,\"id\":\"ex1\",\"cmd\":\"customer.create\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":" + "\"Extras AB\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + int64_t cust = jint(d, "result.id"); + CHECK(cust > 0); + yyjson_doc_free(d); + + /* a text row contributes nothing and is flagged in the response */ + d = call(reqf("{\"v\":1,\"id\":\"ex2\",\"cmd\":\"invoice.issue\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":" + "%lld,\"invoice_date\":\"2026-09-21\",\"due_date\":" + "\"2026-10-21\",\"rows\":[{\"description\":\"Rubrik\"," + "\"text\":true},{\"description\":\"Konsult\"," + "\"quantity\":\"1\",\"unit\":\"st\",\"unit_price_ore\":" + "100000,\"vat_code\":\"25\"}]}}", + g_session, (int)t->org_id, (long long)cust)); + CHECK_OK(d); + int64_t inv = jint(d, "result.id"); + CHECK(jint(d, "result.total_ore") == 125000); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ex3\",\"cmd\":\"invoice.get\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, (int)t->org_id, (long long)inv)); + CHECK_OK(d); + CHECK(jbool(d, "result.rows.0.is_text")); + CHECK_STR(d, "result.rows.0.description", "Rubrik"); + CHECK(jint(d, "result.rows.0.amount_ore") == 0); + CHECK(!jbool(d, "result.rows.1.is_text")); + CHECK(jint(d, "result.rows.1.amount_ore") == 100000); + CHECK_STR(d, "result.paid_date", ""); + yyjson_doc_free(d); + + /* an all-text draft has no total */ + d = call(reqf("{\"v\":1,\"id\":\"ex4\",\"cmd\":\"invoice.issue\"," + "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":" + "{\"customer_id\":%lld,\"invoice_date\":\"2026-09-21\"," + "\"due_date\":\"2026-10-21\",\"rows\":[{\"description\":" + "\"Bara text\",\"text\":true}]}}", + g_session, (int)t->org_id, (long long)cust)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + /* the payment voucher must credit the receivable with the total */ + d = call(reqf("{\"v\":1,\"id\":\"ex5\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-09-21\",\"description\":\"Fel belopp\",\"rows\":" + "[{\"account\":\"1930\",\"debit_ore\":100},{\"account\":" + "\"1510\",\"credit_ore\":100}]}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + int64_t badvoucher = jint(d, "result.id"); + CHECK(badvoucher > 0); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex6\",\"cmd\":\"invoice.pay\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"voucher_id\":%lld}}", + g_session, (int)t->org_id, (long long)inv, + (long long)badvoucher)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + /* the right amount passes, also as a dry run first */ + d = call(reqf("{\"v\":1,\"id\":\"ex7\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-09-22\",\"description\":\"Betalning\",\"rows\":" + "[{\"account\":\"1930\",\"debit_ore\":125000}," + "{\"account\":\"1510\",\"credit_ore\":125000}]}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + int64_t voucher = jint(d, "result.id"); + CHECK(voucher > 0); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex8\",\"cmd\":\"invoice.pay\"," + "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":" + "{\"id\":%lld,\"voucher_id\":%lld}}", + g_session, (int)t->org_id, (long long)inv, + (long long)voucher)); + CHECK_OK(d); + CHECK(jbool(d, "result.dry_run")); + CHECK_STR(d, "result.paid_date", "2026-09-22"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex9\",\"cmd\":\"invoice.pay\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"voucher_id\":%lld}}", + g_session, (int)t->org_id, (long long)inv, + (long long)voucher)); + CHECK_OK(d); + CHECK_STR(d, "result.paid_date", "2026-09-22"); + const char *ser = jstr(d, "result.voucher_series"); + CHECK(ser && *ser); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex10\",\"cmd\":\"invoice.get\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, (int)t->org_id, (long long)inv)); + CHECK_OK(d); + CHECK_STR(d, "result.paid_date", "2026-09-22"); + CHECK(jint(d, "result.payment_voucher_id") == voucher); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex11\",\"cmd\":\"invoice.pay\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"voucher_id\":%lld}}", + g_session, (int)t->org_id, (long long)inv, + (long long)voucher)); + CHECK_STR(d, "error.code", "CONFLICT"); + yyjson_doc_free(d); +} + static void test_invoice_send(struct tctx *t) { yyjson_doc *d; @@ -3588,6 +4170,52 @@ static void test_moms_rules(struct tctx *t) yyjson_doc_free(d); } +/* The momsomföring (26xx -> 2650) inside the period must not zero the boxes. */ +static void test_vat_settlement(struct tctx *t) +{ + (void)t; + yyjson_doc *d; + + d = call(reqf("{\"v\":1,\"id\":\"vs1\",\"cmd\":\"org.create\"," + "\"session\":\"%s\",\"args\":{\"name\":\"Omföring AB\"}}", + g_session)); + CHECK_OK(d); + int org = (int)jint(d, "result.id"); + yyjson_doc_free(d); + + const char *rows[] = { + "{\"account\":\"1930\",\"debit_ore\":125000}," + "{\"account\":\"3001\",\"credit_ore\":100000}," + "{\"account\":\"2611\",\"credit_ore\":25000}", + "{\"account\":\"6540\",\"debit_ore\":8000}," + "{\"account\":\"2641\",\"debit_ore\":2000}," + "{\"account\":\"1930\",\"credit_ore\":10000}", + "{\"account\":\"2611\",\"debit_ore\":25000}," + "{\"account\":\"2641\",\"credit_ore\":2000}," + "{\"account\":\"2650\",\"credit_ore\":23000}", + }; + const char *dates[] = { "2026-03-01", "2026-03-02", "2026-03-31" }; + for (size_t i = 0; i < 3; i++) { + d = call(reqf("{\"v\":1,\"id\":\"vs2\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"%s\",\"description\":\"Moms\",\"rows\":[%s]}}", + g_session, org, dates[i], rows[i])); + CHECK_OK(d); + yyjson_doc_free(d); + } + + d = call(reqf("{\"v\":1,\"id\":\"vs3\",\"cmd\":\"report.vat\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"from\":\"2026-03-01\",\"to\":\"2026-03-31\"}}", + g_session, org)); + CHECK_OK(d); + CHECK(vat_box(d, "05") == 100000); + CHECK(vat_box(d, "10") == 25000); + CHECK(vat_box(d, "48") == -2000); + CHECK(vat_box(d, "49") == 23000); + yyjson_doc_free(d); +} + static void test_rules_editor(struct tctx *t) { yyjson_doc *d; @@ -4554,6 +5182,12 @@ static void test_payroll(struct tctx *t) yyjson_doc_free(d); /* post: one balanced voucher, the run and its lines */ + d = call(reqf("{\"v\":1,\"id\":\"p13a\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"series_payroll\",\"value\":\"L9\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"p13\",\"cmd\":\"payroll.run_post\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"period\":\"2026-08\",\"pay_date\":\"2026-08-25\"}}", @@ -4575,6 +5209,13 @@ static void test_payroll(struct tctx *t) g_session, (int)t->org_id, (long long)voucher)); CHECK_OK(d); CHECK_STR(d, "result.source", "payroll"); + CHECK_STR(d, "result.series", "L9"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"p14b\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"series_payroll\",\"value\":\"L\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); yyjson_doc_free(d); char sql[512]; @@ -4811,6 +5452,14 @@ static void test_payslip(struct tctx *t) CHECK_STR(d, "error.code", "NOT_FOUND"); yyjson_doc_free(d); + /* the shared document header colour reaches the payslip too */ + d = call(reqf("{\"v\":1,\"id\":\"ps5b\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"document_header_color\",\"value\":\"#ff0000\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + yyjson_doc_free(d); + char req[512]; snprintf(req, sizeof req, "{\"v\":1,\"id\":\"ps6\",\"cmd\":\"payroll.payslip\"," @@ -4842,12 +5491,21 @@ static void test_payslip(struct tctx *t) CHECK(strstr(text, "-4 208,00") != NULL); CHECK(strstr(text, "Arbetsgivaravgifter 31,42 % betalas av" " arbetsgivaren.") != NULL); + CHECK(strstr(text, "1 0 0 rg") != NULL); + CHECK(count_of(text, "AB Ett") == 2); free(text); } free(pdf); } yyjson_doc_free(rd); free(raw); + + d = call(reqf("{\"v\":1,\"id\":\"ps7\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"document_header_color\",\"value\":\"#314c59\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + yyjson_doc_free(d); } static void test_payslip_mail(struct tctx *t) @@ -5112,6 +5770,7 @@ static const struct ttest TESTS[] = { { "vouchers", test_vouchers, "org_members" }, { "voucher_errors", test_voucher_errors, "org_members" }, { "reports", test_reports, "vouchers" }, + { "imported_closings", test_imported_closings, "org_members" }, { "general_ledger", test_general_ledger, "vouchers" }, { "voucher_list", test_voucher_list, "vouchers" }, { "vat_eskd", test_vat_eskd, "vouchers" }, @@ -5120,16 +5779,20 @@ static const struct ttest TESTS[] = { { "attachments", test_attachments, "vouchers idempotency" }, { "templates", test_templates, "org_members" }, { "ib", test_ib, "org_members" }, + { "ib_carry", test_ib_carry, "org_members" }, { "period_lock", test_period_lock, "fiscal_years" }, { "sie_export", test_sie_export, "ib" }, { "sie_import", test_sie_import, "attachments sie_export" }, { "settings", test_settings, "org_members" }, + { "series", test_series, "settings" }, { "smtp_settings", test_smtp_settings, "org_members" }, { "bank", test_bank, "settings" }, { "invoices", test_invoices, "org_members" }, + { "invoice_extras", test_invoice_extras, "invoices" }, { "invoice_send", test_invoice_send, "invoices" }, { "moms_rules", test_moms_rules, "org_members" }, { "rules_editor", test_rules_editor, "moms_rules" }, + { "vat_settlement", test_vat_settlement, "org_members" }, { "bokslut", test_bokslut, "moms_rules" }, { "sru", test_sru, "bokslut" }, { "year_close", test_year_close, "fiscal_years" }, diff --git a/tests/test_tui.c b/tests/test_tui.c index 84e610e..aa062fc 100644 --- a/tests/test_tui.c +++ b/tests/test_tui.c @@ -1,10 +1,13 @@ /* Unit tests for the TUI widget layer (clients/tui.c). Only the pure parts are exercised here; drawing is smoke-tested over a pty. */ #include <stdio.h> +#include <stdlib.h> #include <string.h> +#include <unistd.h> #include <ncursesw/ncurses.h> +#include "drafts.h" #include "tui.h" #include "util.h" @@ -712,6 +715,96 @@ static void test_rt_footer(void) CHECK(f[0] == '\0'); } +static void test_actions(void) +{ + char buf[256]; + struct tui_action acts[] = { + { "save", "Spara", KEY_F(9), 1, NULL }, + { "del", "Radera utkast", 0, 0, "inget utkast" }, + { "sec", "Sektion", 0, -1, NULL }, + { "arch", "Arkivera", KEY_F(2), 1, NULL }, + }; + + tui_action_label(&acts[0], buf, sizeof buf); + CHECK(strcmp(buf, "Spara") == 0); + tui_action_label(&acts[1], buf, sizeof buf); + CHECK(strcmp(buf, "Radera utkast (inget utkast)") == 0); + + tui_action_hint(acts, 4, 1, buf, sizeof buf); + CHECK(strstr(buf, "F9 = Spara") != NULL); + CHECK(strstr(buf, "F2 = fler") != NULL); + CHECK(strstr(buf, "Arkivera") == NULL); + + tui_action_hint(acts, 4, 0, buf, sizeof buf); + CHECK(strstr(buf, "F9 = Spara") != NULL); + CHECK(strstr(buf, "F2 = Arkivera F2 = fler") != NULL); + CHECK(strstr(buf, "Radera utkast") == NULL); + + tui_action_hint(NULL, 0, 1, buf, sizeof buf); + CHECK(strcmp(buf, "F2 = fler") == 0); +} + +static void test_drafts(void) +{ + char path[128]; + struct drafts d, r; + + snprintf(path, sizeof path, "/tmp/bokf-drafts-test-%d.json", + (int)getpid()); + unlink(path); + drafts_init_path(&d, path); + CHECK(drafts_count(&d, 1, "customer") == 0); + CHECK(drafts_get(&d, 1, "customer", "ny-1") == NULL); + + drafts_put(&d, 1, "customer", "ny-1", "{\"name\":\"A\"}"); + CHECK(drafts_have(&d, 1, "customer", "ny-1")); + CHECK(drafts_count(&d, 1, "customer") == 1); + CHECK(strcmp(drafts_get(&d, 1, "customer", "ny-1"), + "{\"name\":\"A\"}") == 0); + drafts_put(&d, 1, "customer", "ny-1", "{\"name\":\"B\"}"); + CHECK(strcmp(drafts_get(&d, 1, "customer", "ny-1"), + "{\"name\":\"B\"}") == 0); + drafts_put(&d, 2, "customer", "7", "{\"name\":\"C\"}"); + drafts_put(&d, 1, "employee", "3", "{\"name\":\"D\"}"); + CHECK(drafts_count(&d, 1, "customer") == 1); + CHECK(drafts_count(&d, 2, "customer") == 1); + + drafts_init_path(&r, path); + CHECK(drafts_count(&r, 1, "customer") == 1); + CHECK(strcmp(drafts_get(&r, 1, "customer", "ny-1"), + "{\"name\":\"B\"}") == 0); + CHECK(strcmp(drafts_get(&r, 2, "customer", "7"), + "{\"name\":\"C\"}") == 0); + CHECK(strcmp(drafts_get(&r, 1, "employee", "3"), + "{\"name\":\"D\"}") == 0); + drafts_del(&r, 1, "customer", "ny-1"); + CHECK(drafts_count(&r, 1, "customer") == 0); + drafts_free(&r); + + drafts_init_path(&r, path); + CHECK(drafts_count(&r, 1, "customer") == 0); + CHECK(drafts_count(&r, 2, "customer") == 1); + drafts_free(&r); + drafts_free(&d); + unlink(path); + + FILE *f = fopen(path, "w"); + if (f) { + fputs("inte json", f); + fclose(f); + } + drafts_init_path(&r, path); + CHECK(drafts_count(&r, 1, "customer") == 0); + drafts_free(&r); + unlink(path); + + char *a = drafts_new_id(); + char *b = drafts_new_id(); + CHECK(a && b && strncmp(a, "ny-", 3) == 0 && strcmp(a, b) != 0); + free(a); + free(b); +} + int main(void) { test_disp_width(); @@ -733,6 +826,8 @@ int main(void) test_rt_fields(); test_rt_focus(); test_rt_footer(); + test_actions(); + test_drafts(); printf("test_tui: %d checks, %d failures\n", checks, failures); return failures ? 1 : 0; } |
