aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 15:08:58 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 15:08:58 +0200
commitfb21e8efa65662dcb6c2cc7ed693dea91888c3a7 (patch)
tree43f0662b2c61dd596e2be78fdd107463e8aa9591
parent480640a747b81cb39deb1ec24e6e199a9c837fbe (diff)
invoice: render the document as PDF
-rw-r--r--docs/INVOICING.md49
-rw-r--r--src/invoice.c576
-rw-r--r--src/invoice.h66
-rw-r--r--tests/invoice_check.c205
4 files changed, 880 insertions, 16 deletions
diff --git a/docs/INVOICING.md b/docs/INVOICING.md
index 8d08068..f509997 100644
--- a/docs/INVOICING.md
+++ b/docs/INVOICING.md
@@ -62,19 +62,34 @@ Grid (points, origin top-left; refined against the originals in
| Element | x | y |
|---|---|---|
-| Header bar (full width) | 0 | 48–80 |
-| Wordmark | 20 | 55 |
-| `FAKTURA` (right-aligned) | 577 | 55 |
-| Label column (right-aligned at 114) | 57–114 | 96 + 14.7/row |
-| Value column | 178 | same rows |
-| Fakturaadress block | 556 | 142 + 22/line |
-| Table header | 20 | 247 |
-| Table columns | 20, 119, 343, 409, 484, 544 | — |
-| Summary block | 282 / 445 | 514 + 14.7/row |
-| Bankgiro / OCR | 475 | 579 / 592 |
-| `Summa att betala SEK` | 407 | 609 |
-| Footer | 20, 119, 264 | 638 + 14.7/row |
-| Page number | 577 | 820 (right) |
+| Header bar (x 17.3–577.7) | 17.3 | 53.3–75.7 |
+| Wordmark `MAKANDRA AB` (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 |
+| Dröjsmålsränta (3 fixed lines) | 118.87 | 174.03 + 10.47/line |
+| Fakturaadress block (name always first slot) | 370.35 | 115.82 + 14.71/line |
+| Table header bar (x 17.3–577.7) | 17.3 | 242.43–257.85 |
+| Table columns: left edges | 20.10, 118.87 | baseline 253.81 |
+| Table columns: right edges | 365.45, 433.39, 504.15, 574.89 | — |
+| First table row | 20.10/118.87 | 268.53, 14.71/grid row |
+| Summary left labels / values, right-aligned | 329.76 / 365.45 | 518.60, 533.31, 548.03, 569.04, 583.75 |
+| Rule above summary (0.70 pt, black) | 17.3–577.7 | 507.57 |
+| Rule above footer (0.70 pt, black) | 17.3–577.7 | 621.76 |
+| Summary right labels / values, right-aligned | 504.17 / 574.89 | same rows |
+| `OCR` row | 504.15 / 574.89 | 598.46 |
+| `Summa att betala SEK` (bold) | 504.17 / 574.89 | 615.24 |
+| Footer headings (bold 6.375 pt) | 20.10, 118.87, 263.88 | 642.47 |
+| Footer values (9.107 pt) | 20.10, 118.87, 334.63 | 656.60, 671.31, 686.02 |
+| Page number | 567.43 | 819.80 (9.75 pt) |
+
+Table rows: the first row's baseline is 268.53, every grid row is 14.71 pt.
+A row's `description` lines print one grid row apart, except that the
+first extra line starts one blank grid row lower (29.42 pt): the original
+sheets use that gap before the `Period ...` line. The next invoice row
+starts `(description lines + 1)` grid rows below the current one. The
+left and right summary blocks are anchored at fixed positions, not
+reflowed after the table.
Fields, in the original's order (note: the original says
`Betalningsvilkor`, kept for fidelity):
@@ -93,9 +108,11 @@ Fields, in the original's order (note: the original says
- Footer: `Adress` (org name, address), `Kontakt` (phone, e-mail),
`Orgnr`, `Momsregnr`, `Godkänd för F-skatt`.
-Amounts: table cells print kronor with no decimals when whole, otherwise
-two; the summary prints `14 400,00` (space thousands separator, comma
-decimal). A single trailing page number `1`.
+Amounts: table cells and the summary's plain kronor cells print no
+thousands separator and no decimals when the amount is whole, otherwise a
+comma decimal (e.g. `73200`, `12,50`). Only `Summa att betala SEK` is
+grouped and always two decimals: `91 500,00`. A single trailing page
+number `1`.
## 3. Numbering and OCR
diff --git a/src/invoice.c b/src/invoice.c
new file mode 100644
index 0000000..622c05a
--- /dev/null
+++ b/src/invoice.c
@@ -0,0 +1,576 @@
+#include "invoice.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "pdf.h"
+#include "util.h"
+#include "wordmark.h"
+
+#define INK "#314c59"
+#define WHITE "#ffffff"
+#define RULE "#000000"
+
+#define PAGE_LEFT 17.300
+#define PAGE_RIGHT_X 577.700
+#define BAR_HEADER_Y 53.300
+#define BAR_HEADER_H 22.416
+#define BAR_TABLE_Y 242.436
+#define BAR_TABLE_H 15.410
+#define RULE_SUMMARY_Y 507.574
+#define RULE_FOOTER_Y 621.756
+#define RULE_W 0.7005
+
+#define SIZE_VALUE 9.1065
+#define SIZE_LABEL 7.2852
+#define SIZE_FOOT 6.3746
+#define SIZE_PAGE 9.75
+
+#define ROW_STEP 14.7105
+#define DESC_FIRST_STEP 29.4210
+
+#define INFO_VALUE_X 118.8722
+#define INFO_LABEL_RIGHT 114.010
+#define INFO_BASE_Y 101.1053
+#define LABEL_DY 0.3874
+#define DRY_LABEL_Y 172.2070
+#define DRY_LINE_STEP 10.4716
+#define DRY_LINE_Y 174.0293
+#define OUR_REF_Y 209.6830
+#define YOUR_REF_Y 224.3935
+
+#define ADDR_X 370.3521
+#define ADDR_BASE_Y 115.8158
+
+#define TABLE_HEADER_Y 253.8146
+#define COL_ARTICLE_X 20.1015
+#define COL_DESC_X 118.8722
+#define COL_QTY_RIGHT 365.446
+#define COL_UNIT_X 370.3521
+#define COL_PRICE_RIGHT 433.392
+#define COL_NOTE_X 442.4121
+#define COL_ANM_RIGHT 504.147
+#define COL_AMOUNT_RIGHT 574.892
+#define ROW_FIRST_Y 268.5251
+
+#define SUM_LABEL_RIGHT 329.760
+#define SUM_VALUE_RIGHT 365.446
+#define SUM_R_LABEL 504.170
+#define SUM_R_VALUE 574.892
+#define SUM_Y_A 518.6040
+#define SUM_Y_B 533.3146
+#define SUM_Y_C 548.0251
+#define SUM_Y_D 569.0401
+#define SUM_Y_E 583.7506
+#define SUM_Y_OCR 598.4612
+#define SUM_Y_TOTAL 615.2373
+
+#define FOOT_LABEL_Y 642.4721
+#define FOOT_ORG_Y 641.8922
+#define FOOT_NAME_Y 656.6028
+#define FOOT_VAT_Y 655.8664
+#define FOOT_MOMSY 657.1827
+#define FOOT_ADDR_Y 671.3133
+#define FOOT_EMAIL_Y 671.3133
+#define FOOT_FSKATT_Y 671.8932
+#define FOOT_CITY_Y 686.0238
+#define FOOT_PAGE_Y 819.7954
+
+#define WORDMARK_X 21.742
+#define FAKTURA_X 498.400
+
+static void text_at(struct pdf *p, double x, double base, const char *font,
+ double size, const char *rgb, const char *s)
+{
+ if (s && *s)
+ pdf_text(p, x, base, font, size, rgb, s);
+}
+
+static void text_right(struct pdf *p, double right, double base,
+ const char *font, double size, const char *rgb,
+ const char *s)
+{
+ if (s && *s)
+ pdf_text(p, right - pdf_text_width(font, size, s), base, font, size,
+ 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];
+ int len = snprintf(digits, sizeof digits, "%llu",
+ (unsigned long long)v);
+ size_t o = 0;
+
+ if (len < 0)
+ len = 0;
+ for (int i = 0; i < len && o + 1 < n; i++) {
+ if (i > 0 && (len - i) % 3 == 0)
+ out[o++] = ' ';
+ out[o++] = digits[i];
+ }
+ out[o] = '\0';
+}
+
+static uint64_t ore_abs(int64_t v)
+{
+ return v < 0 ? (uint64_t)(-(v + 1)) + 1 : (uint64_t)v;
+}
+
+static void fmt_kronor(int64_t ore, char *out, size_t n, int grouped,
+ int always_cents)
+{
+ char whole[48];
+ uint64_t kr = ore_abs(ore) / 100;
+ uint64_t rest = ore_abs(ore) % 100;
+ const char *sign = ore < 0 ? "-" : "";
+
+ if (grouped)
+ group_digits(whole, sizeof whole, kr);
+ else
+ snprintf(whole, sizeof whole, "%llu", (unsigned long long)kr);
+ if (rest || always_cents)
+ snprintf(out, n, "%s%s,%02llu", sign, whole,
+ (unsigned long long)rest);
+ else
+ snprintf(out, n, "%s%s", sign, whole);
+}
+
+static void fmt_quantity(int64_t milli, char *out, size_t n)
+{
+ uint64_t v = ore_abs(milli);
+ uint64_t whole = v / 1000;
+ uint64_t frac = v % 1000;
+ const char *sign = milli < 0 ? "-" : "";
+
+ if (!frac)
+ snprintf(out, n, "%s%llu", sign, (unsigned long long)whole);
+ else if (frac % 100 == 0)
+ snprintf(out, n, "%s%llu,%llu", sign, (unsigned long long)whole,
+ (unsigned long long)(frac / 100));
+ else if (frac % 10 == 0)
+ snprintf(out, n, "%s%llu,%02llu", sign, (unsigned long long)whole,
+ (unsigned long long)(frac / 10));
+ else
+ snprintf(out, n, "%s%llu,%03llu", sign, (unsigned long long)whole,
+ (unsigned long long)frac);
+}
+
+static int64_t vat_part(int64_t net, int64_t rate)
+{
+ int64_t v = net * rate;
+
+ if (v >= 0)
+ return (v + 50) / 100;
+ return -((-v + 50) / 100);
+}
+
+void invoice_totals(const struct invoice_doc *d, struct invoice_totals *t)
+{
+ if (!t)
+ return;
+ memset(t, 0, sizeof *t);
+ if (!d || (!d->lines && d->nlines))
+ return;
+ for (size_t i = 0; i < d->nlines; i++) {
+ const struct invoice_line *l = &d->lines[i];
+ const char *code = l->vat_code ? l->vat_code : "25";
+
+ t->net_ore += l->amount_ore;
+ if (strcmp(code, "25") == 0)
+ t->net_25 += l->amount_ore;
+ else if (strcmp(code, "12") == 0)
+ t->net_12 += l->amount_ore;
+ else if (strcmp(code, "6") == 0)
+ t->net_6 += l->amount_ore;
+ else
+ t->net_free += l->amount_ore;
+ }
+ t->vat_ore = vat_part(t->net_25, 25) + vat_part(t->net_12, 12) +
+ vat_part(t->net_6, 6);
+ t->total_ore = t->net_ore + t->vat_ore;
+}
+
+int invoice_ocr_check(const char *number_digits)
+{
+ size_t len;
+ int sum = 0;
+
+ if (!number_digits)
+ return -1;
+ len = strlen(number_digits);
+ if (!len)
+ return 0;
+ for (size_t i = 0; i < len; i++)
+ if (number_digits[i] < '0' || number_digits[i] > '9')
+ return -1;
+ for (size_t i = 0; i < len; i++) {
+ int d = number_digits[len - 1 - i] - '0';
+
+ if (i % 2 == 0) {
+ d *= 2;
+ if (d > 9)
+ d -= 9;
+ }
+ sum += d;
+ }
+ return (10 - sum % 10) % 10;
+}
+
+static size_t count_lines(const char *s)
+{
+ size_t n = 1;
+
+ if (!s)
+ return n;
+ for (const char *p = s; *p; p++)
+ if (*p == '\n')
+ n++;
+ return n;
+}
+
+static double desc_offset(size_t line)
+{
+ if (line == 0)
+ return 0.0;
+ if (line == 1)
+ return DESC_FIRST_STEP;
+ return DESC_FIRST_STEP + (double)(line - 1) * ROW_STEP;
+}
+
+static void put_desc_line(struct pdf *p, double y, const char *s, size_t len)
+{
+ char buf[512];
+
+ if (!len)
+ return;
+ snprintf(buf, sizeof buf, "%.*s", (int)len, s);
+ pdf_text(p, COL_DESC_X, y, "H", SIZE_VALUE, INK, buf);
+}
+
+static void put_address(struct pdf *p, double x, double y, const char *rgb,
+ double size, const char *postal, const char *city)
+{
+ char buf[128];
+
+ if (!postal)
+ postal = "";
+ if (!city)
+ city = "";
+ if (*postal && *city)
+ snprintf(buf, sizeof buf, "%s %s", postal, city);
+ else
+ snprintf(buf, sizeof buf, "%s%s", postal, city);
+ if (*buf)
+ pdf_text(p, x, y, "H", size, rgb, buf);
+}
+
+static int doc_fits(const struct invoice_doc *d)
+{
+ double y = ROW_FIRST_Y;
+
+ for (size_t i = 0; i < d->nlines; i++) {
+ const char *s = d->lines[i].description;
+ size_t n = count_lines(s);
+
+ if (!s)
+ s = "";
+ for (size_t k = 0; k < n; k++) {
+ if (y + desc_offset(k) > RULE_SUMMARY_Y - 4.0)
+ return 0;
+ s = strchr(s, '\n');
+ if (s)
+ s++;
+ }
+ y += (double)(n + 1) * ROW_STEP;
+ }
+ return 1;
+}
+
+static void draw_info(struct pdf *p, const struct invoice_doc *d)
+{
+ static const char *const labels[5] = {
+ "Fakturanummer", "Fakturadatum", "Leveransdatum", "Ert momsreg.nr",
+ "Betalningsvilkor",
+ };
+ char number[32], payment[32];
+ const char *values[5];
+
+ snprintf(number, sizeof number, "%lld", (long long)d->number);
+ snprintf(payment, sizeof payment, "%d dagar", d->payment_days);
+ values[0] = number;
+ values[1] = d->invoice_date;
+ values[2] = d->delivery_date;
+ values[3] = d->customer.vat_nr;
+ values[4] = payment;
+
+ for (size_t i = 0; i < 5; i++) {
+ double y = INFO_BASE_Y + (double)i * ROW_STEP;
+
+ text_right(p, INFO_LABEL_RIGHT, y + LABEL_DY, "HB", SIZE_LABEL, INK,
+ labels[i]);
+ text_at(p, INFO_VALUE_X, y, "H", SIZE_VALUE, INK, values[i]);
+ }
+ text_right(p, INFO_LABEL_RIGHT, DRY_LABEL_Y, "HB", SIZE_LABEL, INK,
+ "Dröjsmålsränta");
+ text_at(p, INFO_VALUE_X, DRY_LINE_Y, "H", SIZE_VALUE, INK,
+ "Vid betalning efter");
+ text_at(p, INFO_VALUE_X, DRY_LINE_Y + DRY_LINE_STEP, "H", SIZE_VALUE, INK,
+ "förfallodagen debiteras");
+ text_at(p, INFO_VALUE_X, DRY_LINE_Y + 2 * DRY_LINE_STEP, "H", SIZE_VALUE,
+ INK, "ränta enligt räntelagen");
+ text_right(p, INFO_LABEL_RIGHT, OUR_REF_Y + LABEL_DY, "HB", SIZE_LABEL,
+ INK, "Vår referens");
+ text_at(p, INFO_VALUE_X, OUR_REF_Y, "H", SIZE_VALUE, INK, d->our_ref);
+ text_right(p, INFO_LABEL_RIGHT, YOUR_REF_Y + LABEL_DY, "HB", SIZE_LABEL,
+ INK, "Er referens");
+ text_at(p, INFO_VALUE_X, YOUR_REF_Y, "H", SIZE_VALUE, INK, d->your_ref);
+}
+
+static void draw_customer(struct pdf *p, const struct invoice_customer *c)
+{
+ const char *s = c->address ? c->address : "";
+ double y = ADDR_BASE_Y;
+
+ text_at(p, ADDR_X, INFO_BASE_Y, "HB", SIZE_VALUE, INK, "Fakturaadress");
+ text_at(p, ADDR_X, y, "H", SIZE_VALUE, INK, c->name);
+ y += ROW_STEP;
+ for (;;) {
+ const char *nl = strchr(s, '\n');
+
+ if (nl) {
+ char buf[512];
+
+ snprintf(buf, sizeof buf, "%.*s", (int)(nl - s), s);
+ if (*buf)
+ pdf_text(p, ADDR_X, y, "H", SIZE_VALUE, INK, buf);
+ y += ROW_STEP;
+ s = nl + 1;
+ } else {
+ if (*s)
+ pdf_text(p, ADDR_X, y, "H", SIZE_VALUE, INK, s);
+ break;
+ }
+ }
+ put_address(p, ADDR_X, y + ROW_STEP, INK, SIZE_VALUE, c->postal_code,
+ c->city);
+}
+
+static void draw_table(struct pdf *p, const struct invoice_doc *d)
+{
+ double y = ROW_FIRST_Y;
+
+ pdf_fill_rect(p, PAGE_LEFT, BAR_TABLE_Y, PAGE_RIGHT_X - PAGE_LEFT,
+ BAR_TABLE_H, INK);
+ text_at(p, COL_ARTICLE_X, TABLE_HEADER_Y, "HB", SIZE_VALUE, WHITE,
+ "Art.nr");
+ text_at(p, COL_DESC_X, TABLE_HEADER_Y, "HB", SIZE_VALUE, WHITE,
+ "Beskrivning");
+ text_right(p, COL_QTY_RIGHT, TABLE_HEADER_Y, "HB", SIZE_VALUE, WHITE,
+ "Antal");
+ text_right(p, COL_PRICE_RIGHT, TABLE_HEADER_Y, "HB", SIZE_VALUE, WHITE,
+ "á-pris");
+ text_right(p, COL_ANM_RIGHT, TABLE_HEADER_Y, "HB", SIZE_VALUE, WHITE,
+ "Anm");
+ text_right(p, COL_AMOUNT_RIGHT, TABLE_HEADER_Y, "HB", SIZE_VALUE, WHITE,
+ "Belopp");
+
+ for (size_t i = 0; i < d->nlines; i++) {
+ const struct invoice_line *l = &d->lines[i];
+ char qty[32], price[48], amount[48];
+ const char *s = l->description ? l->description : "";
+ size_t line = 0;
+
+ 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);
+
+ 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;
+ }
+ }
+ text_at(p, COL_ARTICLE_X, y, "H", SIZE_VALUE, INK, l->article_no);
+ text_right(p, COL_QTY_RIGHT, y, "H", SIZE_VALUE, INK, qty);
+ text_at(p, COL_UNIT_X, y, "H", SIZE_VALUE, INK, l->unit);
+ text_right(p, COL_PRICE_RIGHT, y, "H", SIZE_VALUE, INK, price);
+ text_at(p, COL_NOTE_X, y, "H", SIZE_VALUE, INK, l->note);
+ text_right(p, COL_AMOUNT_RIGHT, y, "H", SIZE_VALUE, INK, amount);
+
+ y += (double)(line + 2) * ROW_STEP;
+ }
+}
+
+static void draw_summary(struct pdf *p, const struct invoice_doc *d,
+ const struct invoice_totals *t)
+{
+ char value[64], total[64];
+
+ pdf_line(p, PAGE_LEFT, RULE_SUMMARY_Y, PAGE_RIGHT_X, RULE_SUMMARY_Y,
+ RULE_W, RULE);
+
+ text_right(p, SUM_LABEL_RIGHT, SUM_Y_A + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Momsunderlag");
+ text_right(p, SUM_R_LABEL, SUM_Y_A + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Belopp före moms");
+ fmt_kronor(t->net_ore, value, sizeof value, 0, 0);
+ text_right(p, SUM_R_VALUE, SUM_Y_A, "H", SIZE_VALUE, INK, value);
+
+ text_right(p, SUM_LABEL_RIGHT, SUM_Y_B + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Moms 25%");
+ fmt_kronor(t->net_25, value, sizeof value, 0, 0);
+ text_right(p, SUM_VALUE_RIGHT, SUM_Y_B, "H", SIZE_VALUE, INK, value);
+ text_right(p, SUM_R_LABEL, SUM_Y_B + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Total moms");
+ fmt_kronor(t->vat_ore, value, sizeof value, 0, 0);
+ text_right(p, SUM_R_VALUE, SUM_Y_B, "H", SIZE_VALUE, INK, value);
+
+ text_right(p, SUM_LABEL_RIGHT, SUM_Y_C + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Moms 12%");
+ fmt_kronor(t->net_12, value, sizeof value, 0, 0);
+ text_right(p, SUM_VALUE_RIGHT, SUM_Y_C, "H", SIZE_VALUE, INK, value);
+ text_right(p, SUM_R_LABEL, SUM_Y_C + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Öresutjämning");
+
+ text_right(p, SUM_LABEL_RIGHT, SUM_Y_D + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Moms 6%");
+ fmt_kronor(t->net_6, value, sizeof value, 0, 0);
+ text_right(p, SUM_VALUE_RIGHT, SUM_Y_D, "H", SIZE_VALUE, INK, value);
+ text_right(p, SUM_R_LABEL, SUM_Y_D + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Förfallodatum");
+ text_right(p, SUM_R_VALUE, SUM_Y_D, "H", SIZE_VALUE, INK, d->due_date);
+
+ text_right(p, SUM_LABEL_RIGHT, SUM_Y_E + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Momsfritt");
+ fmt_kronor(t->net_free, value, sizeof value, 0, 0);
+ text_right(p, SUM_VALUE_RIGHT, SUM_Y_E, "H", SIZE_VALUE, INK, value);
+ text_right(p, SUM_R_LABEL, SUM_Y_E + LABEL_DY, "H", SIZE_LABEL, INK,
+ "Bankgiro");
+ text_right(p, SUM_R_VALUE, SUM_Y_E, "H", SIZE_VALUE, INK,
+ d->seller.bankgiro);
+
+ text_right(p, SUM_R_LABEL, SUM_Y_OCR, "H", SIZE_VALUE, INK, "OCR");
+ text_right(p, SUM_R_VALUE, SUM_Y_OCR, "H", SIZE_VALUE, INK, d->ocr);
+
+ text_right(p, SUM_R_LABEL, SUM_Y_TOTAL, "HB", SIZE_VALUE, INK,
+ "Summa att betala SEK");
+ fmt_kronor(t->total_ore, total, sizeof total, 1, 1);
+ text_right(p, SUM_R_VALUE, SUM_Y_TOTAL, "HB", SIZE_VALUE, INK, total);
+}
+
+static void draw_footer(struct pdf *p, const struct invoice_doc *d)
+{
+ const struct invoice_seller *s = &d->seller;
+
+ pdf_line(p, PAGE_LEFT, RULE_FOOTER_Y, PAGE_RIGHT_X, RULE_FOOTER_Y, RULE_W,
+ RULE);
+ text_at(p, COL_ARTICLE_X, FOOT_LABEL_Y, "HB", SIZE_FOOT, INK, "Adress");
+ text_at(p, INFO_VALUE_X, FOOT_LABEL_Y, "HB", SIZE_FOOT, INK, "Kontakt");
+ text_at(p, 263.8759, FOOT_LABEL_Y, "HB", SIZE_FOOT, INK, "Orgnr");
+ text_at(p, COL_ARTICLE_X, FOOT_NAME_Y, "H", SIZE_VALUE, INK, s->name);
+ if (s->phone && *s->phone) {
+ char phone[64];
+
+ snprintf(phone, sizeof phone, " %s", s->phone);
+ pdf_text(p, INFO_VALUE_X, FOOT_NAME_Y, "H", SIZE_VALUE, INK, phone);
+ }
+ text_at(p, COL_ARTICLE_X, FOOT_ADDR_Y, "H", SIZE_VALUE, INK, s->address);
+ text_at(p, INFO_VALUE_X, FOOT_EMAIL_Y, "H", SIZE_VALUE, INK, s->email);
+ put_address(p, COL_ARTICLE_X, FOOT_CITY_Y, INK, SIZE_VALUE, s->postal_code,
+ s->city);
+ text_at(p, 334.6266, FOOT_ORG_Y, "H", SIZE_VALUE, INK, s->org_nr);
+ text_at(p, 263.8759, FOOT_MOMSY, "HB", SIZE_FOOT, INK, "Momsregnr");
+ text_at(p, 334.6266, FOOT_VAT_Y, "H", SIZE_VALUE, INK, s->vat_nr);
+ text_at(p, 263.8759, FOOT_FSKATT_Y, "HB", SIZE_FOOT, INK,
+ "Godkänd för F-skatt");
+ text_at(p, 567.4275, FOOT_PAGE_Y, "H", SIZE_PAGE, INK, "1");
+}
+
+int invoice_render_pdf(const struct invoice_doc *d, unsigned char **out,
+ size_t *len)
+{
+ struct invoice_totals t;
+ struct pdf *p;
+
+ if (out)
+ *out = NULL;
+ if (len)
+ *len = 0;
+ if (!d || !out || !len)
+ return -1;
+ if (d->nlines && !d->lines)
+ return -1;
+ if (!doc_fits(d))
+ return -1;
+
+ invoice_totals(d, &t);
+ 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);
+ draw_info(p, d);
+ draw_customer(p, &d->customer);
+ draw_table(p, d);
+ draw_summary(p, d, &t);
+ draw_footer(p, d);
+ *out = pdf_finish(p, len);
+ return *out ? 0 : -1;
+}
diff --git a/src/invoice.h b/src/invoice.h
new file mode 100644
index 0000000..c92677d
--- /dev/null
+++ b/src/invoice.h
@@ -0,0 +1,66 @@
+#ifndef BOKF_INVOICE_H
+#define BOKF_INVOICE_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+struct invoice_seller {
+ const char *name;
+ const char *address;
+ const char *postal_code;
+ const char *city;
+ const char *phone;
+ const char *email;
+ const char *org_nr;
+ const char *vat_nr;
+ const char *bankgiro;
+};
+
+struct invoice_customer {
+ const char *name;
+ const char *address;
+ const char *postal_code;
+ const char *city;
+ const char *vat_nr;
+};
+
+struct invoice_line {
+ const char *article_no;
+ const char *description;
+ int64_t quantity_milli;
+ const char *unit;
+ int64_t unit_price_ore;
+ int64_t amount_ore;
+ const char *note;
+ const char *vat_code;
+};
+
+struct invoice_doc {
+ struct invoice_seller seller;
+ struct invoice_customer customer;
+ int64_t number;
+ const char *ocr;
+ const char *invoice_date;
+ const char *due_date;
+ const char *delivery_date;
+ const char *our_ref;
+ const char *your_ref;
+ const char *notes;
+ int payment_days;
+ const struct invoice_line *lines;
+ size_t nlines;
+};
+
+struct invoice_totals {
+ int64_t net_ore;
+ int64_t vat_ore;
+ int64_t total_ore;
+ int64_t net_25, net_12, net_6, net_free;
+};
+
+void invoice_totals(const struct invoice_doc *d, struct invoice_totals *t);
+int invoice_ocr_check(const char *number_digits);
+int invoice_render_pdf(const struct invoice_doc *d, unsigned char **out,
+ size_t *len);
+
+#endif
diff --git a/tests/invoice_check.c b/tests/invoice_check.c
new file mode 100644
index 0000000..46b1621
--- /dev/null
+++ b/tests/invoice_check.c
@@ -0,0 +1,205 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "invoice.h"
+
+static int failures;
+
+static void check(int cond, const char *what)
+{
+ if (!cond) {
+ printf("FAIL %s\n", what);
+ failures++;
+ } else {
+ printf("ok %s\n", what);
+ }
+}
+
+static void eq64(const char *what, int64_t got, int64_t want)
+{
+ if (got != want) {
+ printf("FAIL %s: got %lld want %lld\n", what, (long long)got,
+ (long long)want);
+ failures++;
+ } else {
+ printf("ok %s: %lld\n", what, (long long)got);
+ }
+}
+
+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" },
+};
+
+static void make_doc(struct invoice_doc *d)
+{
+ memset(d, 0, sizeof *d);
+ d->seller.name = "Testleverantör AB";
+ d->seller.address = "Testvägen 1";
+ d->seller.postal_code = "12345";
+ d->seller.city = "Teststad";
+ d->seller.phone = "+46(0)70 - 00 00 000";
+ d->seller.email = "test@example.invalid";
+ d->seller.org_nr = "559000-0000";
+ d->seller.vat_nr = "SE559000000001";
+ d->seller.bankgiro = "0000-0000";
+ d->customer.name = "Testkund AB";
+ d->customer.address = "Kundgatan 2";
+ d->customer.postal_code = "54321";
+ d->customer.city = "Kundstad";
+ d->customer.vat_nr = "SE556000000001";
+ d->number = 1001;
+ d->ocr = "100102";
+ d->invoice_date = "2026-02-01";
+ d->due_date = "2026-03-03";
+ d->delivery_date = "2026-02-01";
+ d->our_ref = "Test Person";
+ d->your_ref = "Kund Person";
+ d->payment_days = 30;
+ d->lines = synthetic_lines;
+ d->nlines = 2;
+}
+
+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" },
+ };
+ struct invoice_doc d;
+ struct invoice_totals t;
+
+ make_doc(&d);
+ d.lines = lines;
+ d.nlines = sizeof lines / sizeof lines[0];
+ invoice_totals(&d, &t);
+ eq64("net", t.net_ore, 204000);
+ eq64("vat", t.vat_ore, 31600);
+ eq64("total", t.total_ore, 235600);
+ eq64("net 25", t.net_25, 100000);
+ eq64("net 12", t.net_12, 50000);
+ eq64("net 6", t.net_6, 10000);
+ eq64("net free", t.net_free, 44000);
+
+ {
+ static const struct invoice_line half[] = {
+ { "", "half", 1000, "st", 100, 50, "", "25" },
+ };
+
+ d.lines = half;
+ d.nlines = 1;
+ invoice_totals(&d, &t);
+ eq64("vat half up", t.vat_ore, 13);
+ }
+
+ invoice_totals(NULL, &t);
+ eq64("null doc net", t.net_ore, 0);
+ eq64("null doc total", t.total_ore, 0);
+ invoice_totals(&d, NULL);
+ check(1, "null totals accepted");
+}
+
+static void test_ocr(void)
+{
+ check(invoice_ocr_check("7992739871") == 3, "luhn canonical");
+ check(invoice_ocr_check("804") == 5, "luhn 804");
+ check(invoice_ocr_check("13006") == 2, "luhn 13006");
+ check(invoice_ocr_check("23506") == 9, "luhn 23506");
+ check(invoice_ocr_check("0") == 0, "luhn zero");
+ check(invoice_ocr_check("") == 0, "luhn empty");
+ check(invoice_ocr_check("12a") == -1, "luhn non-digit");
+ check(invoice_ocr_check(NULL) == -1, "luhn null");
+}
+
+static int contains(const unsigned char *data, size_t len, const char *needle)
+{
+ size_t n = strlen(needle);
+
+ if (n > len)
+ return 0;
+ for (size_t i = 0; i + n <= len; i++)
+ if (memcmp(data + i, needle, n) == 0)
+ return 1;
+ return 0;
+}
+
+static void test_render(const char *path)
+{
+ struct invoice_doc d;
+ struct invoice_totals t;
+ unsigned char *data = NULL;
+ size_t len = 0;
+
+ make_doc(&d);
+ invoice_totals(&d, &t);
+ eq64("synthetic net", t.net_ore, 8320000);
+ eq64("synthetic vat", t.vat_ore, 1830000);
+ eq64("synthetic total", t.total_ore, 10150000);
+
+ check(invoice_render_pdf(&d, &data, &len) == 0, "render returns 0");
+ check(data != NULL && len > 1000, "render bytes");
+ if (data) {
+ check(memcmp(data, "%PDF-1.4", 8) == 0, "render header");
+ check(contains(data, len, "Testleverant"), "render seller text");
+ 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");
+ }
+ if (path && data) {
+ FILE *f = fopen(path, "wb");
+
+ if (!f) {
+ printf("FAIL cannot write %s\n", path);
+ failures++;
+ } else {
+ fwrite(data, 1, len, f);
+ fclose(f);
+ printf("wrote %s (%zu bytes)\n", path, len);
+ }
+ }
+ free(data);
+
+ {
+ unsigned char *bad = NULL;
+ size_t n = 0;
+
+ check(invoice_render_pdf(NULL, &bad, &n) == -1, "render null doc");
+ check(bad == NULL && n == 0, "render null clears out");
+ check(invoice_render_pdf(&d, NULL, &n) == -1, "render null out");
+ check(invoice_render_pdf(&d, &bad, NULL) == -1, "render null len");
+ d.lines = NULL;
+ d.nlines = 1;
+ check(invoice_render_pdf(&d, &bad, &n) == -1, "render null lines");
+ }
+ {
+ struct invoice_doc many;
+ static struct invoice_line rows[40];
+ unsigned char *bad = NULL;
+ size_t n = 0;
+
+ make_doc(&many);
+ for (size_t i = 0; i < 40; i++)
+ rows[i] = synthetic_lines[0];
+ many.lines = rows;
+ many.nlines = 40;
+ check(invoice_render_pdf(&many, &bad, &n) == -1, "render overflows");
+ }
+}
+
+int main(int argc, char **argv)
+{
+ test_totals();
+ test_ocr();
+ test_render(argc > 1 ? argv[1] : NULL);
+ if (failures)
+ printf("%d failures\n", failures);
+ else
+ puts("all ok");
+ return failures ? 1 : 0;
+}