aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile5
-rw-r--r--clients/drafts.c249
-rw-r--r--clients/drafts.h49
-rw-r--r--clients/screens_attachments.c2
-rw-r--r--clients/screens_bokslut.c2
-rw-r--r--clients/screens_ib.c4
-rw-r--r--clients/screens_invoices.c799
-rw-r--r--clients/screens_payroll.c6
-rw-r--r--clients/screens_settings.c90
-rw-r--r--clients/screens_templates.c2
-rw-r--r--clients/screens_vouchers.c43
-rw-r--r--clients/tui.c193
-rw-r--r--clients/tui.h23
-rw-r--r--clients/ui.c105
-rw-r--r--clients/ui.h8
-rw-r--r--docs/DECISIONS.md70
-rw-r--r--docs/INVOICING.md38
-rw-r--r--docs/PAYROLL.md8
-rw-r--r--docs/PROTOCOL.md114
-rw-r--r--docs/SCHEMA.md12
-rw-r--r--docs/STATE.md150
-rw-r--r--docs/TUI-GUIDELINES.md131
-rwxr-xr-xscripts/tui-golden.py172
-rw-r--r--src/cmd_bokslut.c9
-rw-r--r--src/cmd_invoices.c181
-rw-r--r--src/cmd_payroll.c12
-rw-r--r--src/cmd_settings.c64
-rw-r--r--src/db.c62
-rw-r--r--src/db.h5
-rw-r--r--src/invoice.c34
-rw-r--r--src/invoice.h2
-rw-r--r--src/ledger.c6
-rw-r--r--src/mail.c23
-rw-r--r--src/mail.h4
-rw-r--r--src/payslip.c62
-rw-r--r--src/payslip.h1
-rw-r--r--src/pdf.c36
-rw-r--r--src/pdf.h6
-rw-r--r--src/reports.c79
-rw-r--r--src/reports.h15
-rw-r--r--src/sie.c31
-rw-r--r--src/util.c13
-rw-r--r--src/util.h1
-rw-r--r--tests/invoice_check.c72
-rw-r--r--tests/pdf_check.c9
-rw-r--r--tests/test_core.c667
-rw-r--r--tests/test_tui.c95
47 files changed, 3355 insertions, 409 deletions
diff --git a/Makefile b/Makefile
index 6b246a3..d27a270 100644
--- a/Makefile
+++ b/Makefile
@@ -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,
diff --git a/src/db.c b/src/db.c
index 95ecf3f..200fff8 100644
--- a/src/db.c
+++ b/src/db.c
@@ -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;
diff --git a/src/db.h b/src/db.h
index 4e16f62..b541f66 100644
--- a/src/db.h
+++ b/src/db.h
@@ -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)) {
diff --git a/src/mail.c b/src/mail.c
index ca35f6d..e1b2131 100644
--- a/src/mail.c
+++ b/src/mail.c
@@ -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,
diff --git a/src/mail.h b/src/mail.h
index 5efade5..2c5da2f 100644
--- a/src/mail.h
+++ b/src/mail.h
@@ -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;
diff --git a/src/pdf.c b/src/pdf.c
index c6ae876..5718ed7 100644
--- a/src/pdf.c
+++ b/src/pdf.c
@@ -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;
diff --git a/src/pdf.h b/src/pdf.h
index c6f95ac..9ed4595 100644
--- a/src/pdf.h
+++ b/src/pdf.h
@@ -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,
diff --git a/src/sie.c b/src/sie.c
index ba4c1a8..c2ce19a 100644
--- a/src/sie.c
+++ b/src/sie.c
@@ -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, " ");
diff --git a/src/util.c b/src/util.c
index 9543875..7882642 100644
--- a/src/util.c
+++ b/src/util.c
@@ -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;
diff --git a/src/util.h b/src/util.h
index d854f76..a94cf16 100644
--- a/src/util.h
+++ b/src/util.h
@@ -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;
}