diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-20 15:08:58 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-20 15:08:58 +0200 |
| commit | fb21e8efa65662dcb6c2cc7ed693dea91888c3a7 (patch) | |
| tree | 43f0662b2c61dd596e2be78fdd107463e8aa9591 | |
| parent | 480640a747b81cb39deb1ec24e6e199a9c837fbe (diff) | |
invoice: render the document as PDF
| -rw-r--r-- | docs/INVOICING.md | 49 | ||||
| -rw-r--r-- | src/invoice.c | 576 | ||||
| -rw-r--r-- | src/invoice.h | 66 | ||||
| -rw-r--r-- | tests/invoice_check.c | 205 |
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; +} |
