From 304264494d45a957371d419e5e44c278bbf0b1b1 Mon Sep 17 00:00:00 2001 From: devinderstockholm3219gmailcom Date: Sun, 4 Oct 2026 11:01:09 +0200 Subject: [PATCH 1/4] fix(jobs): email worker listens on the queue's prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startEmailWorker() built its Worker without `prefix: QUEUE_PREFIX`, so BullMQ fell back to its default 'bull:' keys while the email queue writes under 'fakturly:'. The worker started cleanly and processed nothing: overdue notices and payment reminders were enqueued and never sent. The existing jobs test built its own worker with the right prefix, so it stayed green. The new regression test uses the production functions on both ends (getEmailQueue → startEmailWorker) and fails without the fix. The construction-only test worker now uses autorun: false, because a running worker on the same queue is a competing consumer that stole the regression test's job. --- backend/src/jobs/workers/email.worker.ts | 6 +++- backend/tests/integration/jobs.test.ts | 45 ++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/backend/src/jobs/workers/email.worker.ts b/backend/src/jobs/workers/email.worker.ts index 38bc741..373c8b8 100644 --- a/backend/src/jobs/workers/email.worker.ts +++ b/backend/src/jobs/workers/email.worker.ts @@ -8,7 +8,7 @@ import { Worker, type Job } from 'bullmq' import { prisma } from '../../lib/prisma.ts' import { totalDueOre } from '../../lib/money.ts' import { sendOverdueNoticeEmail, sendReminderEmail } from '../../services/email.service.ts' -import { QueueName, bullConnection, type EmailJobData } from '../queues.ts' +import { QueueName, QUEUE_PREFIX, bullConnection, type EmailJobData } from '../queues.ts' export async function processEmailJob(job: Job) { const invoice = await prisma.invoice.findUnique({ @@ -73,6 +73,10 @@ export async function processEmailJob(job: Job) { export function startEmailWorker(): Worker { const worker = new Worker(QueueName.EMAIL, processEmailJob, { connection: bullConnection, + // MUST match the queue's prefix. Without it BullMQ falls back to 'bull' + // and the worker silently watches keys nothing writes to — which is how + // overdue notices and reminders went unsent until 2026-10-04. + prefix: QUEUE_PREFIX, // Higher than the overdue worker: sending is IO-bound and independent per // invoice, so several at once is fine. Still bounded, because a provider // will rate-limit us long before our own machine struggles. diff --git a/backend/tests/integration/jobs.test.ts b/backend/tests/integration/jobs.test.ts index 2d29ec9..b0ae200 100644 --- a/backend/tests/integration/jobs.test.ts +++ b/backend/tests/integration/jobs.test.ts @@ -20,8 +20,8 @@ import { closeQueues, defaultJobOptions } from '../../src/jobs/queues.ts' -import { processEmailJob } from '../../src/jobs/workers/email.worker.ts' -import { redis } from '../helpers.ts' +import { processEmailJob, startEmailWorker } from '../../src/jobs/workers/email.worker.ts' +import { redis, uniqueSuffix } from '../helpers.ts' import type { Job } from 'bullmq' import type { EmailJobData } from '../../src/jobs/queues.ts' @@ -79,7 +79,10 @@ describe('workers can actually be constructed', () => { const worker = new Worker(QueueName.EMAIL, async () => ({ ok: true }), { connection: bullConnection, prefix: QUEUE_PREFIX, - concurrency: 1 + concurrency: 1, + // Built, never started: a running worker here would be a competing + // consumer and steal jobs from the real-worker test below. + autorun: false }) openedWorkers.push(worker) @@ -89,6 +92,42 @@ describe('workers can actually be constructed', () => { }) }) +describe('the real email worker receives what the real email queue sends', () => { + // The test above builds its OWN worker with the right prefix — so it stayed + // green while startEmailWorker() itself had no prefix at all and listened on + // BullMQ's default 'bull:' keys. Every overdue notice and reminder went into + // 'fakturly:email:*' and was never read. This test uses the production + // functions on both ends, so the two can never drift apart unnoticed again. + test('🔑 a job added via getEmailQueue() is processed by startEmailWorker()', async () => { + // Leftover jobs from other test files would otherwise be processed by + // this worker too, and could send real emails for real test invoices. + await getEmailQueue().drain() + + const invoiceId = `clnotarealinvoice${uniqueSuffix()}` + const worker = startEmailWorker() + openedWorkers.push(worker) + + // Listen BEFORE adding, so a fast worker cannot finish first. + const completed = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('startEmailWorker() never picked up the job — prefix mismatch?')), + 10_000 + ) + worker.on('completed', (job, result) => { + if (job.data.invoiceId !== invoiceId) return + clearTimeout(timer) + resolve(result) + }) + }) + + await getEmailQueue().add('prefix-regression', { kind: 'overdue-notice', invoiceId }) + + // The invoice does not exist, so the job completes as "skipped" — no + // email is sent, but it proves the job travelled queue → Redis → worker. + expect(await completed).toEqual({ skipped: 'invoice_missing' }) + }, 15_000) +}) + describe('job options', () => { test('retry with exponential backoff', () => { expect(defaultJobOptions.attempts).toBe(3) From 45f0976b07989d6dd6264e511f4e1873fa4b10ae Mon Sep 17 00:00:00 2001 From: devinderstockholm3219gmailcom Date: Sun, 4 Oct 2026 11:01:49 +0200 Subject: [PATCH 2/4] docs: add the roadmap for multi-tenancy, payments, BankID and beyond --- README.md | 2 +- docs/roadmap.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 docs/roadmap.md diff --git a/README.md b/README.md index b7a261b..e9bfaf4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Not a CRUD tutorial — every decision is documented with its reasoning and the [![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](https://react.dev) [![CI](https://github.com/devinder-dev/fakturly/actions/workflows/ci.yml/badge.svg)](https://github.com/devinder-dev/fakturly/actions/workflows/ci.yml) -**[📐 Architecture decisions](docs/architecture-decisions.md)** · **[🚀 Deployment](docs/deployment.md)** · **[🎤 How to present it](docs/presentation.md)** · **[📖 Auth walkthrough](docs/auth-phase-walkthrough.md)** · **[🔌 Integrations](docs/integrations.md)** +**[📐 Architecture decisions](docs/architecture-decisions.md)** · **[🚀 Deployment](docs/deployment.md)** · **[🎤 How to present it](docs/presentation.md)** · **[📖 Auth walkthrough](docs/auth-phase-walkthrough.md)** · **[🔌 Integrations](docs/integrations.md)** · **[🗺️ Roadmap](docs/roadmap.md)** diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..3fa3e11 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,162 @@ +# Roadmap — from single-company demo to Swedish invoicing SaaS + +Fakturly today is a complete invoicing system for **one** company: invoices, +VAT, credit notes, late interest under räntelagen, reminder fees, Stripe +payments, SIE export, an append-only ledger and an audit log. + +This roadmap adds what a real Swedish invoicing product needs next, in an +order chosen so every feature is built on the right foundation the first time. + +Decided 2026-10-04. + +--- + +## Principles + +- **Multi-tenancy comes first.** Every later feature is built tenant-scoped from + day one, instead of being retrofitted. +- **Two parallel streams per wave.** One stream changes the core; the other + builds something that does not touch the same files. Each stream works on its + own branch, database, Redis index and ports, so their tests cannot interfere. +- **One phase = one commit = one review.** Every phase ends with a full-flow + test and a walkthrough before the next one starts. +- The existing rules still hold: money in integer öre, an append-only ledger and + audit log, layered architecture, a test for every bug. + +## Decisions + +| Question | Decision | +|---|---| +| When does multi-tenancy happen? | First, as the foundation | +| How many streams at once? | Two | +| BankID scope | Login for clients and admins; an admin links a personnummer to an existing user. Signing is out of scope | +| AI | Last wave, once the whole domain is in place | + +--- + +## Waves + +| Wave | Stream A (core) | Stream B (parallel) | +|---|---|---| +| 0 | Fix the email worker (reminders were never sent) | — | +| 1 | **Multi-tenancy** | **Standalone libraries**: personnummer, reverse OCR, TOTP, encrypted secrets, BgMax parser, BankID client | +| 2 | **Payments v2**: partial and manual payments, Bankgiro import, review queue | **Login security**: TOTP for admins, BankID for everyone | +| 3 | Stripe refunds, recurring invoices | API keys, outbound webhooks | +| 4 | EU customers (VIES, reverse charge, EUR), Peppol e-invoices | Fortnox sync | +| 5 | AI layer: invoice autofill, payment-match suggestions | — | + +ADR numbers are reserved per stream so parallel branches never collide: +wave 1 = 56 (A) and 57–59 (B), wave 2 = 60–64 (A) and 65–69 (B). + +--- + +## Wave 0 — fix before building + +The email worker was created without the queue's Redis key prefix, so it +listened on keys nothing wrote to: overdue notices and reminders were queued +and never sent. One-line fix, plus a regression test that uses the real queue +and the real worker on both ends. + +## Wave 1 + +### A — Multi-tenancy (ADR 56) + +- An `Organization` holds the seller's details (name, org number, VAT number, + bankgiro, F-skatt, payment terms), replacing the single company configured in env. +- `organizationId` on users, clients, invoices and ledger rows. Composite + foreign keys make a cross-tenant invoice or ledger row impossible at the + database level. +- Every repository function takes the organization first; the few deliberate + cross-tenant reads (the nightly overdue job, the Stripe webhook lookup) are + named `…Unscoped` so they are easy to audit. +- One invoice-number series **per organization** — bokföringslagen wants an + unbroken series per business. +- Seller details are **copied onto an invoice when it is sent**, so editing the + company address never changes an invoice that was already issued. +- A cross-tenant test suite: an admin of company B gets the same 404 for company + A's data as for data that doesn't exist — and a guard fails CI if a new route + has no such test. +- Phases: organizations and the token claim → tenant columns and scoped writes → + scoped reports and dashboard → seller details from the organization → settings + page → demo with two companies. + +### B — Standalone libraries (ADRs 57–59) + +Pure code with no database, verified against the primary specifications: + +- **Personnummer and organisationsnummer** validation (Skatteverket SKV 704/709), + and a keyed HMAC so a personnummer can be looked up without being stored. +- **Reverse OCR**: from a payment reference back to the invoice number. +- **TOTP** (RFC 6238), tested against the RFC's own vectors, with replay protection. +- **Encrypted secrets at rest**: AES-256-GCM with versioned keys for rotation. +- **BgMax parser** for Bankgirot's incoming-payment files, tested against + Bankgirot's own example files. +- **BankID** Relying Party API v6 client with mutual TLS, the animated QR code, + and a stub that behaves like the real service for the demo. + +## Wave 2 + +### A — Payments v2 (ADRs 60–64) + +- Payments become rows of their own (Stripe, Bankgiro, manual), each + idempotent. An invoice's paid amount is derived from the ledger. +- **Partial payments**, and late interest calculated on what is actually still owed. +- **Overpayments are recorded, never rejected** — money that arrived is a fact. +- **Bankgiro import**: upload a BgMax file; payments are matched to invoices by + OCR reference; anything that doesn't match goes to a review queue. Importing + the same file twice changes nothing. +- An admin page for imports, the review queue and manual matching. + +### B — Login security (ADRs 65–69) + +- **TOTP two-factor login** for admins, with recovery codes. Admins without it + are guided to set it up, never locked out. +- **BankID login** for clients and admins, with the animated QR code and "open + BankID on this device". +- The strength of a session (password, password + TOTP, BankID) travels with the + session, so admin-only actions can require it. + +## Wave 3 + +- **A — Refunds and recurring invoices.** Full or partial refunds of paid + invoices through Stripe, booked as a credit note plus a refund in the ledger. + Recurring invoices generated by a scheduled job, never twice for the same period. +- **B — API keys and outbound webhooks.** Organization-scoped API keys, stored + hashed. Webhooks delivered through a transactional outbox, signed like + Stripe's, retried with backoff, with protection against requests to internal + addresses. + +## Wave 4 + +- **A — EU customers and Peppol.** VAT-number check against VIES, reverse + charge, EUR invoices at Riksbanken's rate on the issue date, and Peppol BIS + Billing 3.0 export (required when invoicing the Swedish public sector). +- **B — Fortnox sync.** OAuth2 from the client side, tokens encrypted at rest, + invoices synced through the wave-3 outbox. + +## Wave 5 — AI layer + +- **Invoice autofill**: paste text or upload a document; the client, lines, VAT + and due date are proposed as a **draft** that a person always reviews before + sending. +- **Payment-match suggestions** for Bankgiro payments that didn't match on their own. +- Designed around: what data leaves the server, documents treated as data and + never as instructions, every field validated before use, and a test set to + measure accuracy. + +--- + +## How each wave is verified + +- Backend: typecheck and the full test suite; frontend: build and unit tests; + Playwright end-to-end where the wave adds a user flow. +- A full-flow test in the browser at the end of every phase. +- A security scan at the checkpoints that matter: authorization changes, file + upload, login flows, outbound requests, OAuth callbacks. +- CI green on `main` after both streams of a wave have merged. + +## Not scheduled yet + +- A platform-admin UI for creating organizations (wave 1 uses a script) +- A dashboard for background jobs +- Security headers and CI hardening From 5d7b8f9c34aaa375fb64558538b0d089c2b9cbe3 Mon Sep 17 00:00:00 2001 From: devinderstockholm3219gmailcom Date: Thu, 15 Oct 2026 11:06:49 +0200 Subject: [PATCH 3/4] docs(roadmap): status after wave 1 phase 2, and the decisions made since --- docs/roadmap.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 3fa3e11..d700b3f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,7 +7,19 @@ payments, SIE export, an append-only ledger and an audit log. This roadmap adds what a real Swedish invoicing product needs next, in an order chosen so every feature is built on the right foundation the first time. -Decided 2026-10-04. +Decided 2026-10-04. Status updated 2026-10-15. + +## Status + +| Wave | Status | +|---|---| +| 0 | Done — in review | +| 1 A — Multi-tenancy | In progress: phases 1–2 of 6 done (organizations; scoped writes and single-row reads) | +| 1 B — Standalone libraries | In progress: phases 1–2 of 4 done (personnummer + reverse OCR; TOTP + encrypted secrets) | +| 2–5 | Not started | + +Every phase so far has also had an independent code review; its findings are +fixed in a separate commit before the next phase starts. --- @@ -30,6 +42,9 @@ Decided 2026-10-04. | When does multi-tenancy happen? | First, as the foundation | | How many streams at once? | Two | | BankID scope | Login for clients and admins; an admin links a personnummer to an existing user. Signing is out of scope | +| A customer who buys from several companies | **One login, one customer record per company.** Staff belong to exactly one company. Adding a customer answers the same whether the address is new or already known, so one company can't probe another's customers | +| Draft invoices | Hidden from the customer portal — a draft hasn't been issued | +| Samordningsnummer without a real date | Accepted — Skatteverket issues them, and rejecting them would lock real people out of BankID | | AI | Last wave, once the whole domain is in place | --- @@ -71,6 +86,9 @@ and the real worker on both ends. named `…Unscoped` so they are easy to audit. - One invoice-number series **per organization** — bokföringslagen wants an unbroken series per business. +- Staff belong to one organization; a customer has one login and a customer + record per seller, and sees every seller's invoices in one portal. A database + constraint enforces the difference. - Seller details are **copied onto an invoice when it is sent**, so editing the company address never changes an invoice that was already issued. - A cross-tenant test suite: an admin of company B gets the same 404 for company From eb09a00ec5b08de4c7b451ffab8bd0e9862a80e8 Mon Sep 17 00:00:00 2001 From: devinderstockholm3219gmailcom Date: Thu, 15 Oct 2026 12:11:45 +0200 Subject: [PATCH 4/4] docs(roadmap): BgMax parser done --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index d700b3f..86cc398 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,7 @@ Decided 2026-10-04. Status updated 2026-10-15. |---|---| | 0 | Done — in review | | 1 A — Multi-tenancy | In progress: phases 1–2 of 6 done (organizations; scoped writes and single-row reads) | -| 1 B — Standalone libraries | In progress: phases 1–2 of 4 done (personnummer + reverse OCR; TOTP + encrypted secrets) | +| 1 B — Standalone libraries | In progress: phases 1–3 of 4 done (personnummer + reverse OCR; TOTP + encrypted secrets; BgMax parser, tested against Bankgirot's own example files) | | 2–5 | Not started | Every phase so far has also had an independent code review; its findings are