Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**

</div>

Expand Down
6 changes: 5 additions & 1 deletion backend/src/jobs/workers/email.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmailJobData>) {
const invoice = await prisma.invoice.findUnique({
Expand Down Expand Up @@ -73,6 +73,10 @@ export async function processEmailJob(job: Job<EmailJobData>) {
export function startEmailWorker(): Worker<EmailJobData> {
const worker = new Worker<EmailJobData>(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.
Expand Down
45 changes: 42 additions & 3 deletions backend/tests/integration/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)

Expand All @@ -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<unknown>((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)
Expand Down
180 changes: 180 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
@@ -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
Loading