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/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) diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..86cc398 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,180 @@ +# 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. 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–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 +fixed in a separate commit before the next phase starts. + +--- + +## 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 | +| 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 | + +--- + +## 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. +- 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 + 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