diff --git a/.compliance/Data_Classification_Handling.md b/.compliance/Data_Classification_Handling.md
new file mode 100644
index 00000000..39844fc1
--- /dev/null
+++ b/.compliance/Data_Classification_Handling.md
@@ -0,0 +1,25 @@
+# Data Classification and Handling
+
+## Restricted data
+
+Swedish personal identity numbers are Restricted personal data. They are not an
+Article 9 special category by themselves, but their stable government identifier
+role requires heightened protection.
+
+Controls:
+
+- Customer personal numbers are accepted only for individual customers.
+- Values are encrypted with AES-256-GCM before database storage.
+- API and UI output exposes only the last four digits.
+- Writes require an authenticated company member with write permission.
+- RLS and explicit `company_id` filters enforce tenant isolation.
+- There is no endpoint that returns the full value.
+- Logs and audit event payloads must never contain the full value.
+
+## Internal business data
+
+Article master records are Internal business data. An unused article may be
+deleted because issued invoice lines, archived invoice PDFs, journal entries,
+and audit events retain the accounting evidence independently. Any article that
+is referenced by an invoice line is protected by the application check and the
+database foreign key.
diff --git a/.compliance/dsar_runbook.md b/.compliance/dsar_runbook.md
new file mode 100644
index 00000000..60c00eb8
--- /dev/null
+++ b/.compliance/dsar_runbook.md
@@ -0,0 +1,23 @@
+# Data Subject Rights Runbook
+
+## Customer identity data
+
+For access, correction, restriction, portability, or erasure requests, first
+verify the requester and the tenant relationship. Search the company-scoped
+customer record and any retained accounting documents. Customer master data may
+be corrected or erased when no legal retention duty applies. Accounting records
+and issued invoice documents remain retained for the statutory period; document
+the Article 17(3)(b) exception in the response.
+
+Full personal numbers are never returned through the ordinary customer API.
+Exports and support evidence must use the masked value unless a separately
+approved identity-verification procedure requires otherwise.
+
+## Article master data
+
+Article master records are not personal data and have no independent retention
+duty. The delete endpoint allows deletion only when no invoice item references
+the article. Issued invoice lines contain frozen descriptions, accounts, VAT
+values, and amounts, while archived PDFs and journal records remain immutable.
+This preserves the verification chain and the seven-year accounting retention
+period even when an unused article master row is deleted.
diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml
index 2557a49c..a10779af 100644
--- a/.compliance/ropa.yaml
+++ b/.compliance/ropa.yaml
@@ -8,6 +8,41 @@
processing_activities:
+ - id: customer.private_identity
+ name: Personnummer för privatkund
+ purpose: >-
+ Identifiera en privatkund när företagets avtal, fakturering eller
+ kundadministration kräver en entydig identitet. Personnumret får endast
+ sparas på kundtypen privatkund och exponeras endast maskerat i API och UI.
+ lawful_basis: art_6_1_b
+ special_category_basis: null
+ controller: gnubok-tenant
+ processor: supabase
+ data_subjects:
+ - customer
+ data_categories:
+ - user.government_id
+ recipients:
+ - name: Supabase
+ country: EU
+ role: processor
+ international_transfers:
+ applicable: false
+ mechanism: null
+ note: EU-only processor.
+ retention:
+ duration: customer_relationship_or_7y_if_accounting_record
+ basis: contract_and_bfl_7_kap
+ stored_in:
+ - customers.personal_number
+ - retained_invoice_documents_when_required
+ security_measures:
+ - aes_256_gcm_field_encryption
+ - masked_api_and_ui_output_last4_only
+ - write_role_required
+ - rls_company_scoped
+ - no_full_value_read_endpoint
+
- id: agi.submit
name: AGI inlämning till Skatteverket
purpose: >-
diff --git a/.gitignore b/.gitignore
index e6c7f4a7..863f28b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -94,3 +94,5 @@ scripts/reopen-bokslut.sql
.claude/plans/write-up-a-plan-streamed-fiddle.md
/ingaende-balanser-test.csv
+.agents
+.codex
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..9c8c3f73
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,106 @@
+# CLAUDE.md: Accounted
+
+Swedish accounting SaaS: double-entry bookkeeping under Swedish accounting law (Bokföringslagen) for sole traders (enskild firma) and limited companies (aktiebolag). Multi-tenant: users belong to companies via `company_members`; `teams` group companies for consultants.
+
+**Stack**: Next.js 16 (App Router), React 19, TypeScript 5 strict, Zod 4, Supabase (Postgres + RLS + auth), Tailwind 4 + shadcn/ui. Vercel-hosted is the primary target; Docker self-hosted must keep working but never at hosted's expense. Path alias `@/*` = repo root. All code, comments, and commits in English.
+
+---
+
+## Hard Rules
+
+The accounting rules are Swedish law, enforced by DB triggers. Code that violates them fails at runtime; code that works around the triggers breaks legal compliance. Never do either.
+
+1. **Never edit or delete a posted journal entry.** Committed vouchers are immutable. Cancel with `reverseEntry()`; correct with `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`). Storno, never edit.
+2. **All journal writes go through `lib/bookkeeping/engine.ts`.** Never insert into journal tables directly: voucher numbers are assigned atomically by the `commit_journal_entry` RPC and must stay sequential, and gaps require documented explanations (BFNAR 2013:2, `voucher_gap_explanations`).
+3. **Every entry balances**: `sum(debits) === sum(credits)`, both `> 0`.
+4. **Respect period locks.** DB triggers block writes to closed/locked periods and behind the company lock date. Don't work around them: fix the flow that tried to write there.
+5. **Never delete documents linked to posted entries**: 7-year retention is a legal requirement.
+6. **Money math is `Math.round(x * 100) / 100`.** Never `toFixed()`: it returns strings and rounds incorrectly, causing öre-level drift that breaks entry balance.
+7. **Account numbers are strings** (`'1930'`, never `1930`). They are identifiers, not quantities; arithmetic on them is always a bug.
+
+General prohibitions:
+
+- **Never modify an existing migration**: schemas already shipped; create a new migration. Never touch the enforcement triggers (migration 017); they are legally required.
+- **Never leave a remote DB ahead of the repo.** If you `apply_migration` (or run any DDL) against prod, staging, or a preview branch, write the byte-identical SQL into `supabase/migrations/` under the exact applied version in the same change. An applied version with no committed file is an orphan: Supabase branching aborts the next merge to `main` with "Remote migration versions not found in local migrations directory" and blocks every pending migration behind it. The PR preview passes anyway (preview branches fork from prod's history, which already has the orphan), so this only surfaces at merge.
+- **Core code must never import from `@/extensions/`.** CI builds core with zero extensions enabled; a direct import breaks that build. Extensions cannot use dynamic imports (the registry generates static imports via `setup:extensions`).
+- **Don't add dependencies without asking.** This is an AGPL-3.0 project; license compatibility matters, and the dependency surface is audited.
+- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, `gnubok-mcp` npm package. Renaming them breaks live sessions, API keys, and invites.
+- **Treat `.env.local` as pointing at the production database.** Never run seed/cleanup/repair scripts against it without explicit confirmation.
+- **Keep the diff scoped to the request.** No drive-by refactors of untouched code.
+- **Never use em dashes (—) or en dashes (–)** in code, comments, commit messages, or docs. Use a colon, comma, semicolon, or plain hyphen instead, whichever fits the sentence. Exception: a dash character that is the literal subject being parsed, matched, or documented (e.g. mojibake byte-mapping tables, a date-range separator regex) stays as-is; don't launder those into a colon.
+- Never create a NUL/nul file: `\Accounted\NUL`.
+
+## When Uncertain
+
+- **Stop and ask; do not guess.** Especially for anything touching posted entries, the production database, money math, or Swedish tax law.
+- **Swedish domain questions are never answered from training data.** Load the matching `swedish-*` skill (vat, accounting-compliance, invoice-compliance, payroll, year-end-closing, sie-import-export, sru-filing, financial-reporting, asset-accounting, project-accounting, tax-planning, e-invoicing).
+- Scaffolding has skills; use them instead of improvising: `/erp-api-route` (API routes), `/supabase-migration` (migrations), `/create-extension` (extensions), `/frontend-design` (new UI), `vercel:deploy` (deployment).
+
+## Definition of Done
+
+A change is done when all of these hold; iterate until they do:
+
+1. `npm run lint` is clean and `npm test` passes (`npx vitest run
` while iterating).
+2. New or changed logic in `lib/` or `app/api/` has tests: auth 401, validation 400, 404, happy path; mock `@/lib/supabase/server`.
+3. Any change to a trigger, RPC, RLS policy, or DEFERRABLE constraint ships with a `*.pg.test.ts` (`npm run test:pg`).
+4. New UI strings exist in **both** `messages/sv.json` and `messages/en.json`.
+5. If you edited an atom `SKILL.md`, `npm run skills:generate` was run (CI's `skills:check` fails otherwise).
+6. `npm run check:guards` passes if you touched API routes.
+7. Commit is conventional (`feat:`/`fix:`/`refactor:`/`test:`/`docs:`), atomic, branched from `main`.
+8. If the change touches migrations, local and prod are reconciled: every version in prod's `schema_migrations` has a matching file in `supabase/migrations/`, and vice versa. Check before opening the PR (e.g. `list_migrations` / `select version from supabase_migrations.schema_migrations`); a remote-only version means an uncommitted orphan that will fail the merge.
+
+## Commands
+
+```bash
+npm run dev # Dev server (runs setup:extensions first)
+npm run build # Production build (runs setup:extensions first)
+npm run lint # ESLint
+npm test # All Vitest tests
+npx vitest run # Tests in one directory
+npm run test:pg # pg-real tests against real Postgres
+npm run check:guards # Ratchet guard (e.g. no hand-rolled route auth)
+npm run setup:extensions # Regenerate extension registry from extensions.config.json
+npm run skills:generate # Regenerate agent_atom_registry seed after editing an atom SKILL.md
+```
+
+## Architecture
+
+- **Journal entry lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC); `createJournalEntry()` does both. Everything accounting-shaped routes through this engine.
+- **Tenancy**: every business table has `company_id`. Active company resolves in `lib/supabase/middleware.ts`: `gnubok-company-id` cookie → `user_preferences.active_company_id` → first membership. RLS uses `user_company_ids()`; queries still filter by `company_id` explicitly (defense in depth: service-role paths have no RLS).
+- **Auth**: Supabase email+password + TOTP MFA, enforced **application-side**, not in RLS. `NEXT_PUBLIC_REQUIRE_MFA=true` on hosted; `NEXT_PUBLIC_SELF_HOSTED=true` disables MFA. API routes wrap `withRouteContext`: it is the only path that enforces MFA, so never hand-roll `supabase.auth.getUser()` in a route.
+- **Events**: `lib/events/bus.ts` is a module-level singleton. Any route that emits events must call `ensureInitialized()` (`lib/init.ts`) at module level: otherwise extension handlers are never wired and events silently go nowhere.
+- **Supabase clients**: browser `client.ts`, server `createClient()`, service role `createServiceClient()`, cookieless service role `createServiceClientNoCookies()` (lives in `lib/auth/api-keys.ts`; for API-key/MCP paths). Paginate with `fetchAllRows()`: PostgREST silently caps at 1000 rows.
+- **Extensions**: opt-in plugins in `extensions/general//`; `extensions.config.json` is the source of truth for what's enabled. Core must run with zero extensions.
+- **MCP server**: the bookkeeping engine is exposed as 100+ MCP tools (`extensions/general/mcp-server/`), authenticated by `gnubok_sk_` API keys (SHA-256, scoped, default 100 RPM per key).
+- **Types**: import from `@/types` (`types/index.ts`); event types in `lib/events/types.ts`.
+- **User-facing errors are Swedish**: map through `lib/errors/get-error-message.ts`.
+- **Cron**: hosted cron jobs live in `vercel.json`, authenticated via `verifyCronSecret()` (`lib/auth/cron.ts`).
+
+## Repository Map
+
+- `lib/bookkeeping/`: engine, entry generators, mapping, templates, BAS 2026 data (`bas-data/`)
+- `lib/core/`: period, year-end, storno, tax codes, audit, documents
+- `lib/events/`, `lib/auth/`, `lib/supabase/`, `lib/api/` (Zod `validateBody`/`validateQuery`)
+- `lib/reports/`: balance sheet, income statement, trial balance, GL, ledgers, VAT, SIE, INK2, NE-bilaga, salary, …
+- `lib/invoices/`, `lib/transactions/`, `lib/import/`, `lib/documents/`, `lib/salary/`, `lib/reconciliation/`, `lib/tax/`, `lib/vat/`, `lib/providers/` (Fortnox/Bokio/Briox/BL/Visma), `lib/skatteverket/`, `lib/currency/`, `lib/bankgiro/`, `lib/deadlines/`, `lib/calendar/`
+- `lib/utils.ts`: `cn()`, `formatCurrency()`, `formatDate()`, `formatOrgNumber()`; `lib/logger.ts`
+- `app/(dashboard)/*` pages; `app/api/*` routes; `supabase/migrations/` schema; `extensions/general/*` plugins
+
+## Testing
+
+Vitest 4, `node` env, tests in `__tests__/`, scope `lib/` + `app/api/` (no component/E2E tests). Helpers in `tests/helpers.ts`: `createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest()`, `parseJsonResponse()`, plus fixture factories (`makeTransaction`, `makeJournalEntry`, `makeInvoice`, …). `vi.clearAllMocks()` + `eventBus.clear()` in `beforeEach`. Trigger/RPC/RLS behavior is tested in `*.pg.test.ts` against real Postgres, not with mocks.
+
+## Detail Loads On Demand
+
+The files below are the shared source of truth for path-specific guidance. Claude Code loads them through their `paths` frontmatter. Codex does not interpret that frontmatter, so before reading, editing, reviewing, or otherwise working with a matching path, read and follow the listed rule. Do not duplicate the rule bodies here.
+
+- `.claude/rules/design.md`: design system, locked tokens (`app/**`, `components/**`)
+- `.claude/rules/i18n.md`: sv/en conventions, "stays Swedish" surfaces
+- `.claude/rules/api-routes.md`: `withRouteContext` route pattern, endpoint map (`app/api/**`)
+- `.claude/rules/database.md`: migration rules, key tables/RPCs/triggers, pg-real (`supabase/migrations/**`)
+- `.claude/rules/mcp-server.md`: MCP tool authoring, staged-operation pattern
+- `.claude/rules/bookkeeping.md`: BAS accounts, VAT treatments/rutor, `lib/core/` services
+
+## Decision Log
+
+When you make a non-obvious choice (picked approach A over B, declined a dependency, stopped because a rule here forbade something), append one line to `DECISIONS.md` (repo root): `[YYYY-MM-DD] : `. Check that file before re-litigating a past decision.
diff --git a/DECISIONS.md b/DECISIONS.md
index 49cb6690..6033f7e9 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -150,4 +150,17 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-07-13] employee_opening_balances created_by preserved via read-then-upsert, not a DB trigger: a BEFORE UPDATE trigger would need a new migration for a pure audit concern; the extra select is one indexed query and the lock trigger already backstops races.
[2026-07-13] Opening balances are authoritative for pre-cutover YTD: runSalaryCalculation now excludes booked runs before the cutover month from the YTD aggregation for employees with opening balances, instead of blocking pre-cutover backdated runs (backfill of history is a supported flow).
[2026-07-13] Superseded the 2026-07-13 decline of the NOT VALID suggestion for migration 20260713100000: Emil asked to resolve the PR findings, and the migration is branch-only (verified absent from prod schema_migrations), so the never-modify-shipped-migrations rule does not apply; staging already recorded the versions, so edits only change what prod runs at merge. Implemented as ADD ... NOT VALID in 20260713100000 + 20260713121000 with VALIDATE split into 20260713123000: VALIDATE in the same transaction as ADD would be a no-op since Postgres holds the ACCESS EXCLUSIVE lock until commit; a separate migration file gets its own transaction and validates under SHARE UPDATE EXCLUSIVE. 20260713123000 applied to staging (no-op VALIDATE) and version recorded.
+[2026-07-14] Codex path-specific guidance stays in the single root AGENTS.md, which dispatches to the shared .claude/rules sources: nested AGENTS.md files were declined to keep one project instruction file, and .codex/config.toml has no Claude-style paths matcher.
+[2026-07-14] MCP company selection is stateless per tool call with shared API-key scopes: membership and viewer write access are rechecked for the selected company, while per-company scope overrides are deferred to avoid stateful connection races and premature configuration complexity.
+[2026-07-14] Credit notes are blocked from paid and partially_paid states with early TypeScript guards plus a database CHECK: early guards prevent orphan payment vouchers and provide clear errors, while the constraint protects every remaining RPC and legacy caller. Production had zero existing violations in a count-only audit, so ADD NOT VALID and VALIDATE ship as separate migrations to avoid scanning under the stronger ADD lock.
+[2026-07-14] Automatic invoice reminder timing uses three strictly increasing company settings from 1 through 365 days, defaulting to 15, 30, and 45: separate columns keep validation and settings forms explicit, while the cron falls back to the legacy schedule if it encounters invalid legacy data.
+[2026-07-14] Credit notes are created as non-editable but hard-deletable drafts and issue through a compare-and-set plus an idempotent bookkeeping repair path: this preserves the original until the reversing voucher and accrual storno are durable, lets failed drafts be recreated with the same KR number, and blocks the generic MCP invoice executors from bypassing the lifecycle.
+[2026-07-14] Invoice send actions mention bookkeeping only when issuance creates a journal entry: accrual invoices and ledger-reversing credit notes say send and post, while ordinary cash-method invoices say only send because they are posted at payment.
+[2026-07-15] Article DELETE performs a company-scoped existence check and blocks when any invoice item references the article before hard deletion; legacy inactive articles remain visible in the register so users can reach and delete them: invoice lines retain frozen accounting values, unused article master data has no retention requirement, and the article-number counter is deliberately not rewound because gaps are harmless.
+[2026-07-15] Invoice copies start as new drafts and omit dates, lifecycle state, payment links, accrual periods, customer references, and recipient-specific ROT/RUT data: copied invoices must never inherit bookkeeping or stale customer-specific data from the issued original.
+[2026-07-15] Manual supplier invoice uploads reuse the existing WORM document archive and supplier_invoices.document_id: accrual and privately paid invoices link the document to their registration-time journal entry, while cash-method invoices retain it on the invoice until the existing payment flow links it to the payment entry.
+[2026-07-15] Supplier payment vouchers surface the supplier invoice PDF as a read-only referenced document instead of moving or duplicating its journal link: the retained source stays on the original registration voucher while the full verification chain remains directly reviewable from the payment voucher.
[2026-07-15] Stornoing an opening_balance entry now clears fiscal_periods.opening_balance_entry_id inside reverseEntry, rather than making the year-end gate skip reversed IB entries: a status-aware gate alone is strictly worse, because the close would then proceed to generateOpeningBalances, whose bare UPDATE of opening_balance_entry_id is rejected by enforce_opening_balance_immutability while the old pointer is still set, trading a clear blocker for an opaque Postgres exception after the period is already locked and closed. Clearing at storno time also lets getOpeningBalances fall through to the duplicate-safe compute_prior_opening_balances RPC (it has no status filter and would otherwise keep rendering a cancelled IB), and mirrors the bank-transaction unlink already in reverseEntry. The write is two statements (flag, then pointer) because the trigger reads OLD.opening_balances_set; same order as replace_period_opening_balance_link. Not fixed by refusing to storno a linked IB (the other candidate): the user's goal was to remove a bogus IB so bokslut could re-book it, and the IB-correct flow can only replace, never remove.
+[2026-07-15] Superseded the hard deletion part of the 2026-07-14 credit note draft decision: numbered credit note drafts are now retained as cancelled rows and reopened on retry so the KR series remains complete.
+[2026-07-15] Customer personnummer uses application field encryption with masked API and UI output rather than a database-only cipher: the existing key custody and AES-256-GCM implementation can protect values before they reach Postgres, while ordinary reads never expose the full identifier.
+[2026-07-15] Credit note creation uses a completion marker plus unique company guards instead of a large creation RPC: incomplete parents are never returned, concurrent requests converge, and all journal writes remain in the bookkeeping engine.
diff --git a/app/(dashboard)/articles/[id]/page.tsx b/app/(dashboard)/articles/[id]/page.tsx
index 36be3f3f..074b66da 100644
--- a/app/(dashboard)/articles/[id]/page.tsx
+++ b/app/(dashboard)/articles/[id]/page.tsx
@@ -22,7 +22,7 @@ import {
Package,
Wrench,
Edit2,
- Archive,
+ Trash2,
Loader2,
Lock,
} from 'lucide-react'
@@ -55,6 +55,7 @@ export default function ArticleDetailPage({
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
@@ -127,36 +128,38 @@ export default function ArticleDetailPage({
}
}
- async function handleDeactivate() {
+ async function handleDelete() {
if (!article) return
const ok = await confirmAction({
- title: t('deactivate_confirm_title', { name: article.name }),
- description: t('deactivate_confirm_description'),
- confirmLabel: t('deactivate_confirm_label'),
+ title: t('delete_confirm_title', { name: article.name }),
+ description: t('delete_confirm_description'),
+ confirmLabel: t('delete_confirm_label'),
variant: 'destructive',
})
if (!ok) return
+ setIsDeleting(true)
try {
const response = await fetch(`/api/articles/${id}`, {
method: 'DELETE',
})
- if (!response.ok) {
- throw new Error('Deactivate failed')
- }
+ await throwOnStructuredError(response)
toast({
- title: t('deactivated_title'),
+ title: t('deleted_title'),
description: article.name,
})
router.push('/articles')
- } catch {
+ } catch (err) {
+ const body = (err as { body?: unknown }).body
toast({
- title: t('deactivate_failed_title'),
- description: t('retry'),
+ title: t('delete_failed_title'),
+ description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }),
variant: 'destructive',
})
+ } finally {
+ setIsDeleting(false)
}
}
@@ -175,7 +178,7 @@ export default function ArticleDetailPage({
return (
{/* Header */}
-
+
-
+
:
}
{t('edit')}
- {article.active && (
-
- {canWrite ? : }
- {t('deactivate')}
-
- )}
+
+ {isDeleting ? (
+
+ ) : canWrite ? (
+
+ ) : (
+
+ )}
+ {t('delete')}
+
diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx
index acfd241c..d46bff55 100644
--- a/app/(dashboard)/articles/page.tsx
+++ b/app/(dashboard)/articles/page.tsx
@@ -102,7 +102,6 @@ function ArticlesPageInner() {
.from('articles')
.select('*')
.eq('company_id', company.id)
- .eq('active', true)
.order('name', { ascending: true })
if (error) {
diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx
index 7887f8a0..12389cf5 100644
--- a/app/(dashboard)/customers/[id]/page.tsx
+++ b/app/(dashboard)/customers/[id]/page.tsx
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
+import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number'
import CustomerForm from '@/components/customers/CustomerForm'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
@@ -275,7 +276,7 @@ export default function CustomerDetailPage({
- {/* Business details */}
+ {/* Customer details */}
{t('section_business')}
@@ -287,12 +288,20 @@ export default function CustomerDetailPage({
{customer.customer_number}
)}
- {customer.org_number && (
+ {customer.customer_type !== 'individual' && customer.org_number && (
{t('label_org_number')}
{customer.org_number}
)}
+ {customer.customer_type === 'individual' && (customer.personal_number || customer.org_number) && (
+
+ {t('label_personal_number')}
+
+ {maskCustomerPersonalNumber(customer.personal_number || customer.org_number)}
+
+
+ )}
{customer.vat_number && (
{t('label_vat')}
@@ -306,7 +315,7 @@ export default function CustomerDetailPage({
{t('label_payment_terms')}
{t('payment_terms_value', { days: customer.default_payment_terms || 30 })}
- {!customer.customer_number && !customer.org_number && !customer.vat_number && (
+ {!customer.customer_number && !customer.org_number && !customer.personal_number && !customer.vat_number && (
{t('no_business_info')}
)}
@@ -409,6 +418,7 @@ export default function CustomerDetailPage({
country: customer.country || undefined,
org_number: customer.org_number || undefined,
vat_number: customer.vat_number || undefined,
+ personal_number: customer.personal_number || undefined,
default_payment_terms: customer.default_payment_terms || undefined,
notes: customer.notes || undefined,
}}
diff --git a/app/(dashboard)/invoices/[id]/credit/page.tsx b/app/(dashboard)/invoices/[id]/credit/page.tsx
index 03adc040..5abba896 100644
--- a/app/(dashboard)/invoices/[id]/credit/page.tsx
+++ b/app/(dashboard)/invoices/[id]/credit/page.tsx
@@ -15,6 +15,10 @@ import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { Loader2, ArrowLeft, AlertTriangle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
+import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
+import { useCompany, useCapability } from '@/contexts/CompanyContext'
+import { CAPABILITY } from '@/lib/entitlements/keys'
+import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import type { Invoice, InvoiceItem, Customer } from '@/types'
interface InvoiceWithRelations extends Invoice {
@@ -24,6 +28,8 @@ interface InvoiceWithRelations extends Invoice {
export default function CreateCreditNotePage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
+ const { isSandbox } = useCompany()
+ const canEmail = useCapability(CAPABILITY.email_send)
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
@@ -35,6 +41,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
const [isSubmitting, setIsSubmitting] = useState(false)
const [reason, setReason] = useState('')
const [confirmText, setConfirmText] = useState('')
+ const [createdCreditNote, setCreatedCreditNote] = useState
(null)
+ const [showSendPrompt, setShowSendPrompt] = useState(false)
useEffect(() => {
fetchInvoice()
@@ -114,14 +122,17 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
throw new Error(data.error || t('create_failed_fallback'))
}
- const { data: creditNote } = await response.json()
+ const { data: creditNote } = await response.json() as { data: InvoiceWithRelations }
toast({
title: t('created_toast_title'),
- description: t('created_toast_description', { number: creditNote.invoice_number }),
+ description: creditNote.invoice_number
+ ? t('created_toast_description', { number: creditNote.invoice_number })
+ : undefined,
})
- router.push(`/invoices/${creditNote.id}`)
+ setCreatedCreditNote(creditNote)
+ setShowSendPrompt(true)
} catch (error) {
toast({
title: t('create_failed_title'),
@@ -146,9 +157,30 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
}
const customer = invoice.customer
+ const sendMode = getCreditNoteSendMode({
+ customerHasEmail: !!createdCreditNote?.customer.email,
+ isSandbox,
+ canEmail,
+ })
+
+ function handleSendPromptOpenChange(open: boolean) {
+ setShowSendPrompt(open)
+ if (!open && createdCreditNote) {
+ router.push(`/invoices/${createdCreditNote.id}`)
+ }
+ }
return (
+ {createdCreditNote && (
+
undefined}
+ />
+ )}
{/* Header */}
router.back()} aria-label={t('back')}>
diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx
index 7fcb3f6e..398d2a84 100644
--- a/app/(dashboard)/invoices/[id]/page.tsx
+++ b/app/(dashboard)/invoices/[id]/page.tsx
@@ -15,6 +15,9 @@ import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
+import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
+import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
+import { canCopyInvoice } from '@/lib/invoices/copy-invoice'
import {
Loader2,
ArrowLeft,
@@ -33,8 +36,11 @@ import {
Lock,
CalendarClock,
Pencil,
+ Copy,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
+import { useCompany, useCapability } from '@/contexts/CompanyContext'
+import { CAPABILITY } from '@/lib/entitlements/keys'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
@@ -77,6 +83,8 @@ interface InvoiceWithRelations extends Invoice {
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
+ const { isSandbox } = useCompany()
+ const canEmail = useCapability(CAPABILITY.email_send)
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
@@ -116,6 +124,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [nextNumberPreview, setNextNumberPreview] = useState(null)
const [oreRounding, setOreRounding] = useState(true)
const [vatRegistered, setVatRegistered] = useState(true)
+ const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
+ const [reminderDays, setReminderDays] = useState<[number, number, number]>([15, 30, 45])
const statusLabel = (status: InvoiceStatus): string => t(`status_${status}`)
const reminderLevelLabel = (level: 1 | 2 | 3): string => t(`reminder_level_${level}`)
@@ -210,21 +220,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
data.company_id
? supabase
.from('company_settings')
- .select('ore_rounding, vat_registered')
+ .select('ore_rounding, vat_registered, accounting_method, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
.eq('company_id', data.company_id)
.maybeSingle()
: Promise.resolve(null),
- data.status === 'credited'
+ !data.credited_invoice_id &&
+ ['sent', 'paid', 'overdue', 'credited'].includes(data.status)
? supabase
.from('invoices')
- .select('id, invoice_number')
+ .select('id, invoice_number, status')
.eq('credited_invoice_id', id)
- .single()
+ .neq('status', 'cancelled')
+ .maybeSingle()
: Promise.resolve(null),
data.credited_invoice_id
? supabase
.from('invoices')
- .select('id, invoice_number')
+ .select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
.eq('id', data.credited_invoice_id)
.single()
: Promise.resolve(null),
@@ -243,10 +255,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (typeof settings?.vat_registered === 'boolean') {
setVatRegistered(settings.vat_registered)
}
+ setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
+ setReminderDays([
+ settings?.reminder_days_level_1 ?? 15,
+ settings?.reminder_days_level_2 ?? 30,
+ settings?.reminder_days_level_3 ?? 45,
+ ])
}
- if (creditNoteRes?.data) {
- setCreditNote(creditNoteRes.data as Invoice)
- }
+ setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
if (originalRes?.data) {
setOriginalInvoice(originalRes.data as Invoice)
}
@@ -466,7 +482,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// Unnumbered drafts are hard deleted ("Ta bort"); numbered drafts are
// makulerade and keep their number in the series.
toast(
- invoice.invoice_number
+ invoice.invoice_number && !invoice.credited_invoice_id
? {
title: t('cancelled_toast_title'),
description: t('cancelled_with_number', { number: invoice.invoice_number }),
@@ -477,7 +493,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
)
- router.push('/invoices')
+ router.push(
+ invoice.credited_invoice_id
+ ? `/invoices/${invoice.credited_invoice_id}`
+ : '/invoices',
+ )
} catch (error) {
toast({
title: t('cancel_failed_title'),
@@ -509,6 +529,26 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const isProforma = docType === 'proforma'
const isDeliveryNote = docType === 'delivery_note'
const isRealInvoice = docType === 'invoice'
+ const isCreditNote = !!invoice.credited_invoice_id
+ const booksOnIssue = isCreditNote
+ ? !!originalInvoice && creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
+ : accountingMethod === 'accrual'
+ const preferredSendMode = getCreditNoteSendMode({
+ customerHasEmail,
+ isSandbox,
+ canEmail,
+ })
+ const creditNoteNeedsRepair =
+ isCreditNote &&
+ invoice.status === 'sent' &&
+ !!originalInvoice &&
+ (
+ originalInvoice.status !== 'credited' ||
+ (
+ creditNoteNeedsJournalEntry(accountingMethod, originalInvoice) &&
+ !invoice.journal_entry_id
+ )
+ )
// An unnumbered draft is one saved via "Spara som utkast" that hasn't been
// finalized: no F-number yet, so it can still be reviewed-and-created or
// hard-deleted. Once finalized it gets a number and behaves like any draft.
@@ -525,6 +565,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// in place (header + lines) via /invoices/{id}/edit. Sent/paid invoices are
// immutable (BFL); they are corrected with a credit note instead.
const isEditableDraft = isEditableInvoiceDraft(invoice)
+ const isCopyable = canCopyInvoice(invoice)
const hasAccruedItems = invoice.items.some(itemHasAccrual)
return (
@@ -600,14 +641,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)}
{invoice.status === 'draft' && !isDeliveryNote && invoice.invoice_number && (
- customerHasEmail ? (
+ preferredSendMode === 'email' ? (
openSendDialog('email')}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? : }
- {t('send_via_email')}
+ {t(booksOnIssue ? 'send_via_email_and_book' : 'send_via_email')}
) : (
{canWrite ? : }
- {t('mark_sent_manually')}
+ {t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
)
)}
+ {isCopyable && canWrite && (
+
+
+
+ {t('copy_invoice')}
+
+
+ )}
+ {creditNoteNeedsRepair && (
+
openSendDialog('manual')}
+ disabled={!canWrite}
+ title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
+ >
+ {canWrite ? : }
+ {t('complete_credit_bookkeeping')}
+
+ )}
{isDeliveryNote && invoice.status === 'draft' && (
)}
- {(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
+ {(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && !isCreditNote && (
setShowPaymentDialog(true)}
disabled={isUpdating || !canWrite}
@@ -1079,7 +1139,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{reminders.length === 0 && (
- {t('reminders_description')}
+ {t('reminders_description', {
+ day1: reminderDays[0],
+ day2: reminderDays[1],
+ day3: reminderDays[2],
+ })}
)}
@@ -1135,19 +1199,26 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)}
{/* Credit note reference (if this invoice was credited) */}
- {invoice.status === 'credited' && creditNote && (
-
+ {creditNote && (
+
-
+
- {t('credited_card_title')}
+ {creditNote.status === 'draft'
+ ? t('credit_draft_card_title')
+ : t('credited_card_title')}
- {t('see_credit_note', { number: creditNote.invoice_number ?? '' })}
+ {creditNote.status === 'draft'
+ ? t('open_credit_draft', { number: creditNote.invoice_number ?? '' })
+ : t('see_credit_note', { number: creditNote.invoice_number ?? '' })}
@@ -1201,7 +1272,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)}
{/* Status actions */}
- {invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && (
+ {invoice.status !== 'cancelled' && invoice.status !== 'credited' && (!invoice.credited_invoice_id || invoice.status === 'draft') && (
{t('actions_card_title')}
@@ -1239,7 +1310,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* When the customer has an email the header offers "Send via
email" as the primary; keep the manual-mark-sent path here
as the secondary alternative (it is not in the header). */}
- {!isDeliveryNote && customerHasEmail && (
+ {!isDeliveryNote && preferredSendMode === 'email' && (
<>
openSendDialog('manual')}
>
- {t('mark_sent_manually')}
+ {t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
{t('send_manual_hint_with_email')}
@@ -1261,12 +1332,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
disabled={isDeleting}
>
- {t('delete_draft')}
+ {t(isCreditNote ? 'remove_credit_draft' : 'delete_draft')}
>
)
)}
- {((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice) && (
+ {((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice && !creditNote) && (
@@ -1280,15 +1351,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
- {/* Remove/cancel confirmation. A numbered draft is makulerad (status flips
- to 'cancelled', number retained for a gap-free series); an unnumbered
- draft is hard deleted since it never entered the number series. */}
+ {/* Remove/cancel confirmation. An unissued credit-note draft and an
+ unnumbered invoice draft are hard deleted; other numbered drafts are
+ retained as cancelled to preserve their number series. */}
- {invoice.invoice_number ? t('delete_dialog_title') : t('remove_dialog_title')}
+
+ {isCreditNote
+ ? t('remove_credit_dialog_title')
+ : invoice.invoice_number
+ ? t('delete_dialog_title')
+ : t('remove_dialog_title')}
+
- {invoice.invoice_number ? (
+ {isCreditNote ? (
+ t('remove_credit_dialog_desc')
+ ) : invoice.invoice_number ? (
<>
{t('delete_dialog_desc_with_number_1')}
{t('delete_dialog_status_makulerad')}
@@ -1310,7 +1389,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{isDeleting && }
- {invoice.invoice_number ? t('delete_dialog_confirm') : t('remove_dialog_confirm')}
+ {isCreditNote
+ ? t('remove_credit_dialog_confirm')
+ : invoice.invoice_number
+ ? t('delete_dialog_confirm')
+ : t('remove_dialog_confirm')}
diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx
index 2ea2355c..ef93c73a 100644
--- a/app/(dashboard)/invoices/page.tsx
+++ b/app/(dashboard)/invoices/page.tsx
@@ -87,7 +87,8 @@ export default function InvoicesPage() {
// /invoices/new redirect) opens the same dialog, and the browser back
// button closes it. No canWrite gate here: like the old /invoices/new page,
// the editor itself disables submission for viewers.
- const showNewInvoice = searchParams.has('new')
+ const copyFromId = searchParams.get('copy')
+ const showNewInvoice = searchParams.has('new') || copyFromId !== null
const closeNewInvoice = () => router.replace('/invoices', { scroll: false })
const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false })
@@ -377,6 +378,7 @@ export default function InvoicesPage() {
{
if (!open) closeNewInvoice()
}}
diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx
index 6e9bfd86..88f73160 100644
--- a/app/(dashboard)/supplier-invoices/[id]/page.tsx
+++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx
@@ -13,7 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
-import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus, CalendarClock } from 'lucide-react'
+import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus, CalendarClock, Paperclip } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -22,6 +22,7 @@ import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
+import { DocumentViewButton } from '@/components/bookkeeping/DocumentViewButton'
import { formatAmount, formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
@@ -788,6 +789,26 @@ export default function SupplierInvoiceDetailPage() {
)}
+ {invoice.document_id && (
+
+
+ {t('document_title')}
+
+
+
+
+
+
{t('document_attached')}
+
+
+
+
+
+ )}
+
{/* Journal entries (sambandskrav) */}
diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx
index d24d0a8e..dc4c3749 100644
--- a/app/(public)/privacy/page.tsx
+++ b/app/(public)/privacy/page.tsx
@@ -45,6 +45,7 @@ export default function PrivacyPolicyPage() {
Kontouppgifter: E-postadress (för inloggning)
Företagsuppgifter: Företagsnamn, organisationsnummer, adress, kontaktuppgifter
+ Kundidentitet: Personnummer för privatkunder när det behövs för avtal eller fakturering
Bokföringsdata: Verifikationer, fakturor, kvitton, transaktioner, kontoplaner
Bankdata: Kontosaldon och transaktioner (via PSD2-koppling)
Dokument: Uppladdade kvitton, fakturor och andra bokföringsunderlag
@@ -203,6 +204,10 @@ export default function PrivacyPolicyPage() {
Kontouppgifter: Så länge kontot är aktivt, plus 30 dagar efter
begäran om radering (för att hantera pågående bokföringsplikter).
+
+ Kundidentitet: Under kundrelationen, eller i sju år när uppgiften
+ ingår i räkenskapsinformation som måste bevaras.
+
Tekniska loggar: Maximalt 90 dagar.
diff --git a/app/api/articles/[id]/route.ts b/app/api/articles/[id]/route.ts
index d0807510..4d4397eb 100644
--- a/app/api/articles/[id]/route.ts
+++ b/app/api/articles/[id]/route.ts
@@ -111,10 +111,9 @@ export const PATCH = withRouteContext(
{ requireWrite: true },
)
-// DELETE soft-deactivates (active = false) rather than hard-deleting. Articles
-// are master data referenced by historical invoice lines via a (frozen) copy;
-// keeping the row preserves the register's audit trail and the article number.
-// Re-activate by PATCHing { active: true }.
+// Articles are master data, while invoice lines hold frozen copies of the
+// accounting values. An article may therefore be deleted only while no invoice
+// line references it. The preflight also covers draft invoices.
export const DELETE = withRouteContext(
'article.delete',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
@@ -122,28 +121,66 @@ export const DELETE = withRouteContext(
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ articleId: id })
- const { data, error } = await supabase
+ const { error: articleError } = await supabase
.from('articles')
- .update({ active: false })
+ .select('id')
.eq('id', id)
.eq('company_id', companyId)
- .select()
.single()
- if (error) {
- if (error.code === 'PGRST116') {
+ if (articleError) {
+ if (articleError.code === 'PGRST116') {
return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId })
}
- opLog.error('article deactivate failed', error)
- return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, {
+ opLog.error('article lookup before delete failed', articleError)
+ return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: articleError.message },
})
}
+ const { count: usageCount, error: usageError } = await supabase
+ .from('invoice_items')
+ .select('id', { count: 'exact', head: true })
+ .eq('article_id', id)
+ .eq('company_id', companyId)
+
+ if (usageError) {
+ opLog.error('article usage check failed', usageError)
+ return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
+ requestId,
+ details: { reason: usageError.message },
+ })
+ }
+
+ if ((usageCount ?? 0) > 0) {
+ return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId })
+ }
+
+ const { error: deleteError, count: deletedCount } = await supabase
+ .from('articles')
+ .delete({ count: 'exact' })
+ .eq('id', id)
+ .eq('company_id', companyId)
+
+ if (deleteError) {
+ if (deleteError.code === '23503') {
+ return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId })
+ }
+ opLog.error('article delete failed', deleteError)
+ return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
+ requestId,
+ details: { reason: deleteError.message },
+ })
+ }
+
+ if (deletedCount === 0) {
+ return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId })
+ }
+
await eventBus.emit({
- type: 'article.updated',
- payload: { article: data as Article, companyId: companyId!, userId: user.id },
+ type: 'article.deleted',
+ payload: { articleId: id, companyId, userId: user.id },
})
return NextResponse.json({ success: true })
diff --git a/app/api/articles/__tests__/id.test.ts b/app/api/articles/__tests__/id.test.ts
index ced0ad36..101b8f6a 100644
--- a/app/api/articles/__tests__/id.test.ts
+++ b/app/api/articles/__tests__/id.test.ts
@@ -1,11 +1,13 @@
/**
* Tests for GET/PATCH/DELETE /api/articles/[id] (artikelregister).
*
- * DELETE soft-deactivates (active = false) rather than hard-deleting, so the
- * article and its number survive for history. PATCH is a sparse update.
+ * DELETE permanently removes only articles that have never been used on an
+ * invoice line. PATCH is a sparse update.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
+import { eventBus } from '@/lib/events'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
@@ -97,13 +99,69 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
})
- it('DELETE soft-deactivates and returns success', async () => {
- enqueue({ data: { id: 'a1', active: false } })
+ it('DELETE returns 401 when not authenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase,
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
- const response = await DELETE(createMockRequest('/api/articles/a1', { method: 'DELETE' }), createMockRouteParams({ id: 'a1' }))
+ const response = await DELETE(
+ createMockRequest('/api/articles/a1', { method: 'DELETE' }),
+ createMockRouteParams({ id: 'a1' }),
+ )
+
+ expect(response.status).toBe(401)
+ })
+
+ it('DELETE returns 404 when the article is not found', async () => {
+ enqueue({ data: null, error: { code: 'PGRST116', message: 'not found' } })
+
+ const response = await DELETE(
+ createMockRequest('/api/articles/a1', { method: 'DELETE' }),
+ createMockRouteParams({ id: 'a1' }),
+ )
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
+
+ expect(status).toBe(404)
+ expect(body.error.code).toBe('ARTICLE_NOT_FOUND')
+ })
+
+ it('DELETE rejects an article used on an invoice line', async () => {
+ enqueue({ data: { id: 'a1' }, error: null })
+ enqueue({ data: null, error: null, count: 1 })
+
+ const response = await DELETE(
+ createMockRequest('/api/articles/a1', { method: 'DELETE' }),
+ createMockRouteParams({ id: 'a1' }),
+ )
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
+
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('ARTICLE_IN_USE')
+ expect(supabase.from).toHaveBeenCalledTimes(2)
+ })
+
+ it('DELETE permanently removes an unused article', async () => {
+ enqueue({ data: { id: 'a1' }, error: null })
+ enqueue({ data: null, error: null, count: 0 })
+ enqueue({ data: null, error: null, count: 1 })
+
+ const emitSpy = vi.spyOn(eventBus, 'emit')
+ const response = await DELETE(
+ createMockRequest('/api/articles/a1', { method: 'DELETE' }),
+ createMockRouteParams({ id: 'a1' }),
+ )
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
+ expect(supabase.from).toHaveBeenNthCalledWith(1, 'articles')
+ expect(supabase.from).toHaveBeenNthCalledWith(2, 'invoice_items')
+ expect(supabase.from).toHaveBeenNthCalledWith(3, 'articles')
+ expect(emitSpy).toHaveBeenCalledWith({
+ type: 'article.deleted',
+ payload: { articleId: 'a1', companyId: 'company-1', userId: 'user-1' },
+ })
})
})
diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts
index 0964ef67..81359277 100644
--- a/app/api/customers/[id]/route.ts
+++ b/app/api/customers/[id]/route.ts
@@ -4,6 +4,7 @@ import { UpdateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
export const GET = withRouteContext(
'customer.get',
@@ -37,7 +38,7 @@ export const GET = withRouteContext(
.eq('company_id', companyId)
.order('invoice_date', { ascending: false })
- return NextResponse.json({ data: { ...data, invoices: invoices || [] } })
+ return NextResponse.json({ data: { ...maskCustomerRow(data), invoices: invoices || [] } })
},
)
@@ -55,6 +56,26 @@ export const PATCH = withRouteContext(
if (!result.success) return result.response
const body = result.data
+ const { data: existing, error: existingError } = await supabase
+ .from('customers')
+ .select('id, customer_type')
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .single()
+
+ if (existingError || !existing) {
+ if (existingError?.code === 'PGRST116') {
+ return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
+ }
+ opLog.error('customer lookup before update failed', existingError)
+ return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId })
+ }
+
+ const effectiveType = body.customer_type ?? existing.customer_type
+ if (body.personal_number && effectiveType !== 'individual') {
+ return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId })
+ }
+
const updateData: Record = {}
if (body.name !== undefined) updateData.name = body.name
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
@@ -69,6 +90,11 @@ export const PATCH = withRouteContext(
if (body.country !== undefined) updateData.country = body.country
if (body.org_number !== undefined) updateData.org_number = body.org_number
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
+ if (body.personal_number !== undefined) {
+ updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number)
+ } else if (body.customer_type !== undefined && effectiveType !== 'individual') {
+ updateData.personal_number = null
+ }
if (body.language !== undefined) updateData.language = body.language
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
if (body.notes !== undefined) updateData.notes = body.notes
@@ -82,6 +108,9 @@ export const PATCH = withRouteContext(
.single()
if (error) {
+ if (error.code === 'PGRST116') {
+ return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
+ }
if (error.code === '23505') {
return errorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', opLog, {
requestId,
@@ -127,7 +156,7 @@ export const PATCH = withRouteContext(
}
}
- return NextResponse.json({ data })
+ return NextResponse.json({ data: maskCustomerRow(data) })
},
{ requireWrite: true },
)
diff --git a/app/api/customers/__tests__/personal-number.test.ts b/app/api/customers/__tests__/personal-number.test.ts
new file mode 100644
index 00000000..67629894
--- /dev/null
+++ b/app/api/customers/__tests__/personal-number.test.ts
@@ -0,0 +1,228 @@
+import { NextResponse } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { eventBus } from '@/lib/events'
+import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
+import { decryptPersonnummer } from '@/lib/salary/personnummer'
+
+const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
+let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
+
+const buildChain = (): unknown =>
+ new Proxy(
+ {},
+ {
+ get(_target, prop) {
+ if (prop === 'then') {
+ return (resolve: (value: unknown) => void) => resolve(queryResult)
+ }
+ return (...args: unknown[]) => {
+ if (prop === 'insert') captured.insert.push(args[0])
+ if (prop === 'update') captured.update.push(args[0])
+ return buildChain()
+ }
+ },
+ },
+ )
+
+const supabase = {
+ from: vi.fn(() => buildChain()),
+ rpc: vi.fn(() => buildChain()),
+}
+
+const requireAuthMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+const requireWriteMock = vi.fn()
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+
+vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
+
+import { POST } from '../route'
+import { PATCH } from '../[id]/route'
+
+type CustomerWrite = { personal_number?: string | null }
+
+describe('personal_number on customer routes', () => {
+ const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ eventBus.clear()
+ captured.insert.length = 0
+ captured.update.length = 0
+ queryResult = { data: null, error: null }
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
+ requireWriteMock.mockResolvedValue({ ok: true })
+ })
+
+ it('returns 401 before creating a customer when unauthenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+
+ const response = await POST(
+ createMockRequest('/api/customers', {
+ method: 'POST',
+ body: { name: 'Anna Andersson', customer_type: 'individual' },
+ }),
+ { params: Promise.resolve({}) },
+ )
+
+ expect(response.status).toBe(401)
+ expect(captured.insert).toHaveLength(0)
+ })
+
+ it('returns 400 for an invalid personal number', async () => {
+ const response = await POST(
+ createMockRequest('/api/customers', {
+ method: 'POST',
+ body: {
+ name: 'Anna Andersson',
+ customer_type: 'individual',
+ personal_number: 'not-a-personal-number',
+ },
+ }),
+ { params: Promise.resolve({}) },
+ )
+
+ expect(response.status).toBe(400)
+ expect(captured.insert).toHaveLength(0)
+ })
+
+ it('stores the personal number when creating a private customer', async () => {
+ queryResult = {
+ data: {
+ id: 'customer-1',
+ name: 'Anna Andersson',
+ customer_type: 'individual',
+ personal_number: '19900101-1234',
+ },
+ error: null,
+ }
+
+ const response = await POST(
+ createMockRequest('/api/customers', {
+ method: 'POST',
+ body: {
+ name: 'Anna Andersson',
+ customer_type: 'individual',
+ personal_number: '19900101-1234',
+ },
+ }),
+ { params: Promise.resolve({}) },
+ )
+
+ const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
+ expect(status).toBe(200)
+ const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string
+ expect(encrypted).not.toBe('19900101-1234')
+ expect(decryptPersonnummer(encrypted)).toBe('19900101-1234')
+ expect(body.data.personal_number).toBe('********-1234')
+ })
+
+ it('updates the personal number for an existing private customer', async () => {
+ queryResult = {
+ data: {
+ id: 'customer-1',
+ customer_type: 'individual',
+ personal_number: '900101-1234',
+ },
+ error: null,
+ }
+
+ const response = await PATCH(
+ createMockRequest('/api/customers/customer-1', {
+ method: 'PATCH',
+ body: { personal_number: '900101-1234' },
+ }),
+ routeParams,
+ )
+
+ expect(response.status).toBe(200)
+ const encrypted = (captured.update[0] as CustomerWrite).personal_number as string
+ expect(encrypted).not.toBe('900101-1234')
+ expect(decryptPersonnummer(encrypted)).toBe('900101-1234')
+ })
+
+ it('clears the personal number when null is sent', async () => {
+ queryResult = {
+ data: { id: 'customer-1', customer_type: 'individual', personal_number: null },
+ error: null,
+ }
+
+ const response = await PATCH(
+ createMockRequest('/api/customers/customer-1', {
+ method: 'PATCH',
+ body: { personal_number: null },
+ }),
+ routeParams,
+ )
+
+ expect(response.status).toBe(200)
+ expect((captured.update[0] as CustomerWrite).personal_number).toBeNull()
+ })
+
+ it('does not change the personal number when the field is omitted', async () => {
+ queryResult = {
+ data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' },
+ error: null,
+ }
+
+ const response = await PATCH(
+ createMockRequest('/api/customers/customer-1', {
+ method: 'PATCH',
+ body: { name: 'Anna A' },
+ }),
+ routeParams,
+ )
+
+ expect(response.status).toBe(200)
+ expect(captured.update[0]).not.toHaveProperty('personal_number')
+ })
+
+ it('rejects a personal number for a corporate customer', async () => {
+ queryResult = {
+ data: { id: 'customer-1', customer_type: 'swedish_business' },
+ error: null,
+ }
+
+ const response = await PATCH(
+ createMockRequest('/api/customers/customer-1', {
+ method: 'PATCH',
+ body: { personal_number: '900101-1234' },
+ }),
+ routeParams,
+ )
+
+ const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
+ expect(response.status).toBe(400)
+ expect(body.error.code).toBe('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED')
+ expect(captured.update).toHaveLength(0)
+ })
+
+ it('returns 404 when the customer does not exist', async () => {
+ queryResult = {
+ data: null,
+ error: { code: 'PGRST116', message: 'No rows returned' },
+ }
+
+ const response = await PATCH(
+ createMockRequest('/api/customers/missing', {
+ method: 'PATCH',
+ body: { personal_number: '900101-1234' },
+ }),
+ { params: Promise.resolve({ id: 'missing' }) },
+ )
+
+ expect(response.status).toBe(404)
+ })
+})
diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts
index 10bb84c3..87b0e8b8 100644
--- a/app/api/customers/route.ts
+++ b/app/api/customers/route.ts
@@ -7,6 +7,7 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Customer } from '@/types'
+import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
ensureInitialized()
@@ -26,7 +27,7 @@ export const GET = withRouteContext(
return errorResponse(error, log, { requestId })
}
- return NextResponse.json({ data })
+ return NextResponse.json({ data: (data ?? []).map(maskCustomerRow) })
},
)
@@ -59,6 +60,7 @@ export const POST = withRouteContext(
country: body.country || 'Sweden',
org_number: body.org_number,
vat_number: body.vat_number,
+ personal_number: encryptCustomerPersonalNumber(body.personal_number),
language: body.language || 'sv',
default_payment_terms: body.default_payment_terms || 30,
notes: body.notes,
@@ -104,12 +106,13 @@ export const POST = withRouteContext(
}
}
+ const safeCustomer = maskCustomerRow(data)
await eventBus.emit({
type: 'customer.created',
- payload: { customer: data as Customer, companyId: companyId!, userId: user.id },
+ payload: { customer: safeCustomer as Customer, companyId: companyId!, userId: user.id },
})
- return NextResponse.json({ data })
+ return NextResponse.json({ data: safeCustomer })
},
{ requireWrite: true },
)
diff --git a/app/api/invoices/[id]/__tests__/route.test.ts b/app/api/invoices/[id]/__tests__/route.test.ts
index d35294ae..af31c0a6 100644
--- a/app/api/invoices/[id]/__tests__/route.test.ts
+++ b/app/api/invoices/[id]/__tests__/route.test.ts
@@ -18,13 +18,14 @@ vi.mock('@/lib/init', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
-import { DELETE } from '../route'
+import { DELETE, PATCH } from '../route'
describe('DELETE /api/invoices/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -175,3 +176,53 @@ describe('DELETE /api/invoices/[id]', () => {
expect(status).toBe(500)
})
})
+
+describe('PATCH /api/invoices/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ eventBus.clear()
+ mockSupabase.auth.getUser.mockResolvedValue({
+ data: { user: { id: 'user-1', email: 'test@test.se' } },
+ })
+ })
+
+ it('rejects editing a credit-note draft', async () => {
+ enqueue({
+ data: {
+ id: 'credit-1',
+ status: 'draft',
+ invoice_number: 'KR-F-2026001',
+ journal_entry_id: null,
+ is_self_billed: false,
+ credited_invoice_id: '11111111-1111-4111-8111-111111111111',
+ },
+ error: null,
+ })
+
+ const response = await PATCH(
+ createMockRequest('/api/invoices/credit-1', {
+ method: 'PATCH',
+ body: {
+ customer_id: '22222222-2222-4222-8222-222222222222',
+ invoice_date: '2026-07-14',
+ due_date: '2026-07-14',
+ currency: 'SEK',
+ items: [
+ {
+ description: 'Kredit',
+ quantity: 1,
+ unit: 'st',
+ unit_price: 100,
+ },
+ ],
+ },
+ }),
+ createMockRouteParams({ id: 'credit-1' }),
+ )
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
+
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT')
+ })
+})
diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts
index be34bcb7..0684aab4 100644
--- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts
+++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts
@@ -114,6 +114,42 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(status).toBe(400)
})
+ it('rejects a sent credit note before booking a payment', async () => {
+ const invoice = makeInvoice({
+ status: 'sent',
+ credited_invoice_id: 'original-invoice-1',
+ })
+ enqueue({ data: invoice, error: null })
+
+ const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
+ const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response)
+
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
+ expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
+ expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
+ expect(mockCreateJournalEntry).not.toHaveBeenCalled()
+ })
+
+ it('rejects an original invoice while an active credit-note draft exists', async () => {
+ const invoice = {
+ ...makeInvoice({ status: 'sent', credited_invoice_id: null }),
+ credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }],
+ }
+ enqueue({ data: invoice, error: null })
+
+ const response = await POST(
+ createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }),
+ createMockRouteParams({ id: 'inv-1' }),
+ )
+ const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
+
+ expect(response.status).toBe(400)
+ expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
+ expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
+ })
+
it('marks sent invoice as paid with accrual method', async () => {
const customer = makeCustomer()
const invoice = makeInvoice({
diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts
index e12105e6..3604a27f 100644
--- a/app/api/invoices/[id]/mark-paid/route.ts
+++ b/app/api/invoices/[id]/mark-paid/route.ts
@@ -31,7 +31,7 @@ export const POST = withRouteContext(
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
- .select('*, customer:customers(*), items:invoice_items(*)')
+ .select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -40,6 +40,26 @@ export const POST = withRouteContext(
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
}
+ if (invoice.credited_invoice_id) {
+ return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
+ requestId,
+ details: { reason: 'credit_note' },
+ })
+ }
+
+ const activeCreditNotes = ((invoice as { credit_notes?: Array<{
+ status: string
+ creation_complete?: boolean
+ }> }).credit_notes ?? []).filter(
+ (creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
+ )
+ if (activeCreditNotes.length > 0) {
+ return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
+ requestId,
+ details: { reason: 'active_credit_note' },
+ })
+ }
+
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
requestId,
diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts
index 09000e56..4b86e4c3 100644
--- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts
+++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts
@@ -52,6 +52,17 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
mockCreateInvoiceJournalEntry(...args),
}))
+const mockIssueCreditNote = vi.fn()
+vi.mock('@/lib/invoices/issue-credit-note', () => ({
+ issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
+ creditNoteNeedsJournalEntry: (method: string, original: { status: string; journal_entry_id?: string | null; paid_at?: string | null; paid_amount?: number | null }) =>
+ method === 'accrual' ||
+ !!original.journal_entry_id ||
+ original.status === 'paid' ||
+ !!original.paid_at ||
+ Math.abs(original.paid_amount ?? 0) > 0,
+}))
+
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
@@ -94,18 +105,66 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
+ mockIssueCreditNote.mockResolvedValue({
+ complete: true,
+ journalEntryId: 'credit-je-1',
+ journalEntryRequired: true,
+ failures: [],
+ })
+ })
+
+ it('returns 401 when not authenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: mockSupabase,
+ error: new Response(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ })
+
+ const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(401)
+ })
+
+ it('returns 404 when the invoice does not exist', async () => {
+ enqueue({ data: null, error: { message: 'not found' } })
+
+ const request = createMockRequest('/api/invoices/missing/mark-sent', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'missing' }))
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(404)
+ })
+
+ it('returns 400 when the invoice is not a draft', async () => {
+ enqueue({ data: makeInvoice({ id: 'inv-1', status: 'sent' }), error: null })
+
+ const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(400)
})
it('archives the rendered PDF as underlag linked to the journal entry', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice
- enqueue({ data: null, error: null }) // status update
enqueue({ data: company, error: null }) // settings
+ enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-7' })
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
- const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response)
+ const { status, body } = await parseJsonResponse<{
+ success: boolean
+ journal_entry_id: string | null
+ partial?: boolean
+ partial_failures?: Array<{ step: string }>
+ }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
@@ -128,37 +187,25 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
)
})
- it('archives the PDF even when journal entry creation fails (non-blocking)', async () => {
+ it('restores the draft and fails closed when journal entry creation fails', async () => {
enqueue({ data: invoice, error: null })
- enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
+ enqueue({ data: [{ id: 'inv-1' }], error: null })
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
- const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response)
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
- expect(status).toBe(200)
- expect(body.success).toBe(true)
- expect(body.journal_entry_id).toBeNull()
-
- expect(mockUploadDocument).toHaveBeenCalledTimes(1)
- expect(mockUploadDocument).toHaveBeenCalledWith(
- expect.anything(),
- 'user-1',
- 'company-1',
- expect.objectContaining({ name: 'faktura-F-2026010.pdf' }),
- expect.objectContaining({
- upload_source: 'system',
- journal_entry_id: undefined,
- })
- )
+ expect(status).toBe(500)
+ expect(body.error.code).toBe('INVOICE_MARK_SENT_BOOK_FAILED')
+ expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('still returns 200 when PDF archival itself fails', async () => {
enqueue({ data: invoice, error: null })
- enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
+ enqueue({ data: [{ id: 'inv-1' }], error: null })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-8' })
enqueue({ data: null, error: null })
@@ -183,8 +230,8 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
})
enqueue({ data: proforma, error: null })
- enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
+ enqueue({ data: [{ id: 'inv-2' }], error: null })
const request = createMockRequest('/api/invoices/inv-2/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-2' }))
@@ -196,10 +243,10 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect(mockRenderToBuffer).not.toHaveBeenCalled()
})
- it('uses kreditfaktura filename when archiving a credit note', async () => {
+ it('issues the credit note and uses a credit-note filename when archiving it', async () => {
const creditNote = makeInvoice({
id: 'inv-3',
- invoice_number: 'F-2026011',
+ invoice_number: 'KR-F-2026010',
status: 'draft',
credited_invoice_id: 'inv-1',
customer,
@@ -207,31 +254,132 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
})
enqueue({ data: creditNote, error: null })
- enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
- mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-9' })
- enqueue({ data: null, error: null })
- // Lookup of the original invoice's number for the credit note PDF
- enqueue({ data: { invoice_number: 'F-2026010' }, error: null })
+ const original = {
+ id: 'inv-1',
+ invoice_number: 'F-2026010',
+ status: 'sent',
+ journal_entry_id: 'original-je-1',
+ paid_at: null,
+ paid_amount: null,
+ total: 12500,
+ }
+ enqueue({ data: original, error: null })
+ enqueue({ data: [{ id: 'inv-3' }], error: null })
const request = createMockRequest('/api/invoices/inv-3/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-3' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
+ expect(mockIssueCreditNote).toHaveBeenCalledWith(
+ expect.objectContaining({
+ creditNote: expect.objectContaining({ id: 'inv-3' }),
+ originalInvoice: original,
+ accountingMethod: 'accrual',
+ }),
+ )
+ expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(mockUploadDocument).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
- expect.objectContaining({ name: 'kreditfaktura-F-2026011.pdf' }),
+ expect.objectContaining({ name: 'kreditfaktura-KR-F-2026010.pdf' }),
expect.anything()
)
})
+ it('fails closed and restores the draft when credit-note booking cannot start', async () => {
+ const creditNote = makeInvoice({
+ id: 'credit-1',
+ invoice_number: 'KR-F-2026010',
+ status: 'draft',
+ credited_invoice_id: 'inv-1',
+ customer,
+ items: invoice.items,
+ })
+ enqueue({ data: creditNote, error: null })
+ enqueue({ data: company, error: null })
+ enqueue({
+ data: {
+ id: 'inv-1',
+ invoice_number: 'F-2026010',
+ status: 'sent',
+ journal_entry_id: 'original-je-1',
+ paid_at: null,
+ paid_amount: null,
+ total: 12500,
+ },
+ error: null,
+ })
+ enqueue({ data: [{ id: 'credit-1' }], error: null })
+ mockIssueCreditNote.mockResolvedValue({
+ complete: false,
+ journalEntryId: null,
+ journalEntryRequired: true,
+ failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }],
+ })
+ enqueue({ data: null, error: null })
+
+ const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(500)
+ expect(mockUploadDocument).not.toHaveBeenCalled()
+ })
+
+ it('repairs a sent credit note without running the draft status transition again', async () => {
+ const creditNote = makeInvoice({
+ id: 'credit-1',
+ invoice_number: 'KR-F-2026010',
+ status: 'sent',
+ credited_invoice_id: 'inv-1',
+ journal_entry_id: null,
+ customer,
+ items: invoice.items,
+ })
+ enqueue({ data: creditNote, error: null })
+ enqueue({ data: company, error: null })
+ enqueue({
+ data: {
+ id: 'inv-1',
+ invoice_number: 'F-2026010',
+ status: 'sent',
+ journal_entry_id: 'original-je-1',
+ paid_at: null,
+ paid_amount: null,
+ total: 12500,
+ },
+ error: null,
+ })
+
+ const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(200)
+ expect(mockIssueCreditNote).toHaveBeenCalledOnce()
+ })
+
+ it('returns 409 when another request already marked the draft as sent', async () => {
+ enqueue({ data: invoice, error: null })
+ enqueue({ data: company, error: null })
+ enqueue({ data: [], error: null })
+
+ const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(409)
+ expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
+ expect(mockUploadDocument).not.toHaveBeenCalled()
+ })
+
it('renders the archived PDF as if already sent (no UTKAST banner)', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice (status: 'draft')
- enqueue({ data: null, error: null }) // status update
enqueue({ data: company, error: null }) // settings
+ enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-99' })
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts
index b01b7a0c..de4ade89 100644
--- a/app/api/invoices/[id]/mark-sent/route.ts
+++ b/app/api/invoices/[id]/mark-sent/route.ts
@@ -2,13 +2,28 @@ import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
+import { eventBus } from '@/lib/events'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
+import {
+ creditNoteNeedsJournalEntry,
+ issueCreditNote,
+ type CreditNoteOriginalInvoice,
+} from '@/lib/invoices/issue-credit-note'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { uploadDocument } from '@/lib/core/documents/document-service'
-import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
+import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import type {
+ AccountingMethod,
+ CompanySettings,
+ CreditNote,
+ Customer,
+ EntityType,
+ Invoice,
+ InvoiceItem,
+} from '@/types'
ensureInitialized()
@@ -21,7 +36,7 @@ ensureInitialized()
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.mark_sent',
- async (request, { supabase, user, companyId, log }, { params }) => {
+ async (_request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
// Fetch invoice
@@ -33,14 +48,16 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (invoiceError || !invoice) {
- return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
+ return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
- if (invoice.status !== 'draft') {
- return NextResponse.json(
- { error: 'Endast utkast kan markeras som skickade' },
- { status: 400 }
- )
+ const isCreditNote = !!invoice.credited_invoice_id
+
+ if (!isCreditNote && invoice.status !== 'draft') {
+ return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId })
+ }
+ if (isCreditNote && !['draft', 'sent'].includes(invoice.status)) {
+ return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId })
}
// Assign invoice number now if this draft doesn't have one yet
@@ -48,43 +65,126 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
log.error('failed to assign invoice number on mark-sent', err as Error)
- return NextResponse.json(
- { error: 'Kunde inte tilldela fakturanummer. Försök igen.' },
- { status: 500 }
- )
- }
-
- // Update status to sent
- const { error: updateError } = await supabase
- .from('invoices')
- .update({ status: 'sent' })
- .eq('id', id)
- .eq('company_id', companyId)
-
- if (updateError) {
- return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
+ return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
}
// Fetch full company settings for PDF rendering and accounting method
- const { data: settings } = await supabase
+ const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
- const accountingMethod = settings?.accounting_method || 'accrual'
+ if (settingsError || !settings) {
+ return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId })
+ }
+
+ const accountingMethod = (settings.accounting_method || 'accrual') as AccountingMethod
+ const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
+ let originalInvoice: CreditNoteOriginalInvoice | undefined
+ let originalInvoiceNumber: string | undefined
+
+ if (invoice.credited_invoice_id) {
+ const { data: original } = await supabase
+ .from('invoices')
+ .select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
+ .eq('id', invoice.credited_invoice_id)
+ .eq('company_id', companyId)
+ .single()
+
+ if (!original) {
+ return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', log, { requestId })
+ }
+
+ originalInvoice = original as CreditNoteOriginalInvoice
+ originalInvoiceNumber = original.invoice_number ?? undefined
+ }
+
+ const journalEntryRequired = originalInvoice
+ ? creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
+ : false
+ const isRecovery = isCreditNote && invoice.status === 'sent'
+
+ if (
+ isRecovery &&
+ originalInvoice?.status === 'credited' &&
+ (!journalEntryRequired || !!invoice.journal_entry_id)
+ ) {
+ return errorResponseFromCode('INVOICE_CREDIT_ALREADY_ISSUED', log, { requestId })
+ }
+
+ // Compare-and-set prevents two concurrent requests from posting two journal
+ // entries for the same draft.
+ let statusFlipped = false
+ if (!isRecovery) {
+ const { data: updatedRows, error: updateError } = await supabase
+ .from('invoices')
+ .update({ status: 'sent' })
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .eq('status', 'draft')
+ .select('id')
+
+ if (updateError) {
+ log.error('invoice mark-sent status update failed', updateError)
+ return errorResponseFromCode('INVOICE_MARK_SENT_STATUS_FAILED', log, { requestId })
+ }
+ if (!updatedRows || updatedRows.length === 0) {
+ return errorResponseFromCode('INVOICE_MARK_SENT_RACE', log, { requestId })
+ }
+ statusFlipped = true
+ }
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
- if (isRealInvoice && accountingMethod === 'accrual') {
+ const partialFailures: Array<{ step: string; reason: string }> = []
+
+ if (isCreditNote && originalInvoice) {
+ const issueResult = await issueCreditNote({
+ supabase,
+ companyId,
+ userId: user.id,
+ creditNote: invoice as CreditNote,
+ originalInvoice,
+ entityType,
+ accountingMethod,
+ log,
+ })
+ journalEntryId = issueResult.journalEntryId
+ partialFailures.push(...issueResult.failures)
+
+ if (!issueResult.complete) {
+ // If no immutable entry was created, restoring the draft is safe and
+ // lets the user fix the period/account issue before trying again.
+ if (statusFlipped && issueResult.journalEntryRequired && !issueResult.journalEntryId) {
+ await supabase
+ .from('invoices')
+ .update({ status: 'draft' })
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .eq('status', 'sent')
+ .is('journal_entry_id', null)
+ }
+ return errorResponseFromCode(
+ issueResult.repairRequired
+ ? 'INVOICE_CREDIT_REPAIR_REQUIRED'
+ : 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
+ log,
+ {
+ requestId,
+ details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
+ },
+ )
+ }
+ } else if (isRealInvoice && accountingMethod === 'accrual') {
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
- (settings?.entity_type as EntityType) || 'enskild_firma',
+ entityType,
invoice.customer?.name
)
if (journalEntry) {
@@ -100,12 +200,16 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
- (settings?.entity_type as EntityType) || 'enskild_firma',
+ entityType,
)
if (accrual.failed > 0) {
log.error('accrual schedule creation failed on mark-sent', {
failed: accrual.failed,
})
+ partialFailures.push({
+ step: 'accrual_schedules',
+ reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
+ })
}
const { error: linkError } = await supabase
@@ -122,32 +226,55 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
log.error('mark-sent: journal_entry_id link to invoice failed', linkError, {
journalEntryId: journalEntry.id,
})
+ partialFailures.push({
+ step: 'journal_link',
+ reason: 'Verifikatet skapades men kunde inte kopplas till fakturan.',
+ })
}
+ } else {
+ partialFailures.push({
+ step: 'journal_entry',
+ reason: 'Ingen öppen bokföringsperiod hittades för fakturans datum.',
+ })
}
} catch (err) {
log.error('failed to create invoice journal entry on mark-sent', err as Error)
+ partialFailures.push({
+ step: 'journal_entry',
+ reason: 'Fakturans verifikat kunde inte skapas.',
+ })
}
}
+ if (isRealInvoice && accountingMethod === 'accrual' && !isCreditNote && !journalEntryId) {
+ if (statusFlipped) {
+ const { error: rollbackError } = await supabase
+ .from('invoices')
+ .update({ status: 'draft' })
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .eq('status', 'sent')
+ .is('journal_entry_id', null)
+ if (rollbackError) log.error('failed to restore draft after mark-sent booking failure', rollbackError)
+ }
+ return errorResponseFromCode('INVOICE_MARK_SENT_BOOK_FAILED', log, { requestId })
+ }
+
+ if (partialFailures.some((failure) => failure.step === 'journal_link')) {
+ return errorResponseFromCode('INVOICE_MARK_SENT_REPAIR_REQUIRED', log, {
+ requestId,
+ details: { failure_steps: ['journal_link'] },
+ })
+ }
+
// Render and archive the PDF as underlag so it remains retrievable even if
// the invoice row is later cancelled. Mirrors the send route.
- if (isRealInvoice && settings) {
+ if (isRealInvoice) {
try {
const items = (invoice.items as InvoiceItem[] | null ?? []).slice().sort(
(a, b) => a.sort_order - b.sort_order
)
- let originalInvoiceNumber: string | undefined
- if (invoice.credited_invoice_id) {
- const { data: originalInvoice } = await supabase
- .from('invoices')
- .select('invoice_number')
- .eq('id', invoice.credited_invoice_id)
- .eq('company_id', companyId)
- .single()
- originalInvoiceNumber = originalInvoice?.invoice_number ?? undefined
- }
-
// The DB status flip already happened above, but the in-memory `invoice`
// is stale and still reads 'draft': override here so the archived
// underlag isn't stamped "UTKAST: inte en giltig faktura".
@@ -183,13 +310,27 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
})
} catch (err) {
log.error('failed to archive invoice PDF on mark-sent', err as Error)
+ partialFailures.push({
+ step: 'pdf_archive',
+ reason: 'Fakturans PDF kunde inte arkiveras.',
+ })
}
}
+ if (!isCreditNote) {
+ await eventBus.emit({
+ type: 'invoice.sent',
+ payload: { invoice: { ...(invoice as Invoice), status: 'sent' }, companyId, userId: user.id },
+ })
+ }
+
return NextResponse.json({
success: true,
status: 'sent',
journal_entry_id: journalEntryId,
+ ...(partialFailures.length > 0
+ ? { partial: true, partial_failures: partialFailures }
+ : {}),
})
},
{ requireWrite: true },
diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts
index e278fd5e..a2fd3b1b 100644
--- a/app/api/invoices/[id]/route.ts
+++ b/app/api/invoices/[id]/route.ts
@@ -1,11 +1,7 @@
-import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
-import { requireCompanyId } from '@/lib/company/context'
-import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
-import { createLogger } from '@/lib/logger'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateInvoiceSchema } from '@/lib/api/schemas'
@@ -15,8 +11,6 @@ import type { InvoiceDocumentType } from '@/types'
ensureInitialized() // Module-level: wires the audit-log handler for invoice.draft_deleted.
-const log = createLogger('api.invoices.cancel')
-
/**
* DELETE /api/invoices/[id]
*
@@ -32,37 +26,25 @@ const log = createLogger('api.invoices.cancel')
* Only drafts may be removed either way. Sent / paid invoices are immutable per
* BFL and must be reversed via a credit note instead.
*/
-export async function DELETE(
- request: Request,
- { params }: { params: Promise<{ id: string }> }
-) {
- const { id } = await params
- const supabase = await createClient()
-
- const { data: { user } } = await supabase.auth.getUser()
-
- if (!user) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const writeCheck = await requireWritePermission(supabase, user.id)
- if (!writeCheck.ok) return writeCheck.response
-
- const companyId = await requireCompanyId(supabase, user.id)
+export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
+ 'invoice.delete',
+ async (_request, { user, supabase, companyId, log, requestId }, { params }) => {
+ const { id } = await params
+ const opLog = log.child({ invoiceId: id })
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
- .select('id, status, invoice_number, user_id')
+ .select('id, status, invoice_number, user_id, credited_invoice_id, journal_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !invoice) {
- return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
+ return errorResponseFromCode('INVOICE_NOT_FOUND', opLog, { requestId })
}
if (invoice.status !== 'draft') {
- return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log)
+ return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', opLog, { requestId })
}
// Unnumbered drafts (saved via "Spara som utkast", never finalized) are not
@@ -82,13 +64,14 @@ export async function DELETE(
.select('id')
if (removeError) {
- return NextResponse.json({ error: removeError.message }, { status: 500 })
+ opLog.error('invoice draft delete failed', removeError)
+ return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
}
if (!removed || removed.length === 0) {
// Finalized between fetch and delete: refuse rather than fall through to
// makulering of a now-issued invoice.
- return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
+ return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
}
// The row is gone, so there's no journal trace of the removal. Emit an
@@ -100,7 +83,7 @@ export async function DELETE(
payload: { invoiceId: id, companyId, userId: user.id },
})
- return NextResponse.json({ data: { deleted: true } })
+ return NextResponse.json({ data: { deleted: true } })
}
// Numbered draft: retain the row and its number, flip to 'cancelled'
@@ -118,15 +101,18 @@ export async function DELETE(
.select('id')
if (cancelError) {
- return NextResponse.json({ error: cancelError.message }, { status: 500 })
+ opLog.error('invoice cancellation failed', cancelError)
+ return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
}
if (!updated || updated.length === 0) {
- return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
+ return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
}
- return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
-}
+ return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
+ },
+ { requireWrite: true },
+)
/**
* PATCH /api/invoices/[id]
@@ -161,7 +147,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
// received self-billing document) may be edited.
const { data: existing, error: fetchError } = await supabase
.from('invoices')
- .select('id, status, invoice_number, journal_entry_id, is_self_billed')
+ .select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id')
.eq('id', id)
.eq('company_id', companyId!)
.single()
diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts
index 97b964ab..380d228f 100644
--- a/app/api/invoices/[id]/send/__tests__/route.test.ts
+++ b/app/api/invoices/[id]/send/__tests__/route.test.ts
@@ -66,6 +66,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
mockCreateInvoiceJournalEntry(...args),
}))
+const mockIssueCreditNote = vi.fn()
+vi.mock('@/lib/invoices/issue-credit-note', () => ({
+ issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
+}))
+
// The sandbox guard issues a company_settings query at the top of the route;
// short-circuit it in tests since the queued mock-supabase is shaped for the
// route's existing fetch chain, not an extra pre-flight read.
@@ -113,6 +118,12 @@ describe('POST /api/invoices/[id]/send', () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockIsConfigured.mockReturnValue(true)
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
+ mockIssueCreditNote.mockResolvedValue({
+ complete: true,
+ journalEntryId: 'credit-je-1',
+ journalEntryRequired: true,
+ failures: [],
+ })
})
it('returns 401 when not authenticated', async () => {
@@ -314,6 +325,144 @@ describe('POST /api/invoices/[id]/send', () => {
)
})
+ it('issues and books a credit-note draft through the email send flow', async () => {
+ const creditNote = makeInvoice({
+ id: 'credit-1',
+ invoice_number: 'KR-F-2024001',
+ status: 'draft',
+ credited_invoice_id: 'inv-1',
+ customer,
+ items: (invoice.items ?? []).map((item) => ({
+ ...item,
+ invoice_id: 'credit-1',
+ quantity: -Math.abs(item.quantity),
+ line_total: -Math.abs(item.line_total),
+ vat_amount: -Math.abs(item.vat_amount ?? 0),
+ })),
+ subtotal: -10000,
+ vat_amount: -2500,
+ total: -12500,
+ })
+ const original = {
+ id: 'inv-1',
+ invoice_number: 'F-2024001',
+ status: 'sent',
+ journal_entry_id: 'original-je-1',
+ paid_at: null,
+ paid_amount: null,
+ total: 12500,
+ }
+
+ enqueue({ data: creditNote, error: null })
+ enqueue({ data: company, error: null })
+ enqueue({ data: original, error: null })
+ mockSendEmail.mockResolvedValue({ success: true, messageId: 'credit-message-1' })
+ enqueue({ data: [{ id: 'credit-1' }], error: null })
+ const emitSpy = vi.spyOn(eventBus, 'emit')
+
+ const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
+ const { status, body } = await parseJsonResponse<{ success: boolean; message: string }>(response)
+
+ expect(status).toBe(200)
+ expect(body.success).toBe(true)
+ expect(body.message).toContain('Kreditfakturan har skickats')
+ expect(mockIssueCreditNote).toHaveBeenCalledWith(
+ expect.objectContaining({
+ companyId: 'company-1',
+ creditNote: expect.objectContaining({ id: 'credit-1' }),
+ originalInvoice: original,
+ accountingMethod: 'accrual',
+ }),
+ )
+ expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
+ expect(mockSendEmail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attachments: [
+ expect.objectContaining({ filename: 'kreditfaktura-KR-F-2024001.pdf' }),
+ ],
+ }),
+ )
+ expect(emitSpy).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'invoice.sent' }),
+ )
+ })
+
+ it('does not email a credit note when its bookkeeping cannot be completed', async () => {
+ const creditNote = makeInvoice({
+ id: 'credit-1',
+ invoice_number: 'KR-F-2024001',
+ status: 'draft',
+ credited_invoice_id: 'inv-1',
+ customer,
+ items: invoice.items,
+ })
+ enqueue({ data: creditNote, error: null })
+ enqueue({ data: company, error: null })
+ enqueue({
+ data: {
+ id: 'inv-1',
+ invoice_number: 'F-2024001',
+ status: 'sent',
+ journal_entry_id: 'original-je-1',
+ paid_at: null,
+ paid_amount: null,
+ total: 12500,
+ },
+ error: null,
+ })
+ enqueue({ data: [{ id: 'credit-1' }], error: null })
+ mockIssueCreditNote.mockResolvedValue({
+ complete: false,
+ journalEntryId: null,
+ journalEntryRequired: true,
+ failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }],
+ })
+ enqueue({ data: null, error: null })
+
+ const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(500)
+ expect(mockSendEmail).not.toHaveBeenCalled()
+ })
+
+ it('retries delivery for an already-issued credit note after provider failure', async () => {
+ const creditNote = makeInvoice({
+ id: 'credit-1',
+ invoice_number: 'KR-F-2024001',
+ status: 'sent',
+ credited_invoice_id: 'inv-1',
+ customer,
+ items: invoice.items,
+ })
+ enqueue({ data: creditNote, error: null })
+ enqueue({ data: company, error: null })
+ enqueue({
+ data: {
+ id: 'inv-1',
+ invoice_number: 'F-2024001',
+ status: 'credited',
+ journal_entry_id: 'original-je-1',
+ paid_at: null,
+ paid_amount: null,
+ total: 12500,
+ },
+ error: null,
+ })
+ mockSendEmail.mockResolvedValue({ success: true, messageId: 'retry-message-1' })
+
+ const response = await POST(
+ createMockRequest('/api/invoices/credit-1/send', { method: 'POST' }),
+ createMockRouteParams({ id: 'credit-1' }),
+ )
+
+ expect(response.status).toBe(200)
+ expect(mockIssueCreditNote).toHaveBeenCalledTimes(1)
+ expect(mockSendEmail).toHaveBeenCalledTimes(1)
+ })
+
it('skips journal entry for cash method', async () => {
const cashCompany = makeCompanySettings({ accounting_method: 'cash' })
enqueue({ data: invoice, error: null })
@@ -451,12 +600,10 @@ describe('POST /api/invoices/[id]/send', () => {
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
- // Provider errors map to 502 PROVIDER_FAILED with the provider message in details.
+ // Provider errors map to a safe retryable response without leaking provider text.
expect(status).toBe(502)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_PROVIDER_FAILED')
- expect(
- (body.error as unknown as { details?: { providerError?: string } }).details?.providerError,
- ).toContain('SMTP error')
+ expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true)
})
it('renders the final PDF as if already sent (no UTKAST banner)', async () => {
diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts
index 3d3cb2cf..2a6b4c33 100644
--- a/app/api/invoices/[id]/send/route.ts
+++ b/app/api/invoices/[id]/send/route.ts
@@ -14,13 +14,25 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
+import {
+ issueCreditNote,
+ type CreditNoteOriginalInvoice,
+} from '@/lib/invoices/issue-credit-note'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
-import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
+import type {
+ AccountingMethod,
+ CompanySettings,
+ CreditNote,
+ Customer,
+ EntityType,
+ Invoice,
+ InvoiceItem,
+} from '@/types'
ensureInitialized()
@@ -59,6 +71,9 @@ export const POST = withRouteContext(
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
}
+ const isCreditNote = !!invoice.credited_invoice_id
+ const isCreditDeliveryRetry = isCreditNote && invoice.status === 'sent'
+
// A cancelled invoice keeps its F-series number for compliance with ML 17
// kap 24§ but is not a valid faktura: sending it would deliver a
// "MAKULERAD" PDF as if it were live. Checked before the generic draft
@@ -73,7 +88,7 @@ export const POST = withRouteContext(
// (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
// and orphaning the first entry. Mirrors the v1 route and the MCP commit
// executor, which both reject non-drafts.
- if (invoice.status !== 'draft') {
+ if (invoice.status !== 'draft' && !isCreditDeliveryRetry) {
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
requestId,
details: { currentStatus: invoice.status },
@@ -100,18 +115,22 @@ export const POST = withRouteContext(
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
+ let originalInvoice: CreditNoteOriginalInvoice | undefined
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
- const { data: originalInvoice } = await supabase
+ const { data: original } = await supabase
.from('invoices')
- .select('invoice_number')
+ .select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
- if (originalInvoice) {
- originalInvoiceNumber = originalInvoice.invoice_number
+ if (!original) {
+ return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', opLog, { requestId })
}
+
+ originalInvoice = original as CreditNoteOriginalInvoice
+ originalInvoiceNumber = original.invoice_number ?? undefined
}
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
@@ -149,13 +168,15 @@ export const POST = withRouteContext(
// that the number exists, so the email button and PDF QR carry it. A
// failure never blocks the send: the faktura is legally valid without a
// link, so it degrades to a PARTIAL warning instead.
- const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
- supabase,
- companyId!,
- user.id,
- invoice as Invoice,
- opLog,
- )
+ const { failure: paymentLinkFailure } = isCreditNote
+ ? { failure: undefined }
+ : await applyPaymentLinkToInvoice(
+ supabase,
+ companyId!,
+ user.id,
+ invoice as Invoice,
+ opLog,
+ )
// Final render with the assigned number: this is the buffer attached to
// the email and later archived as underlag. Override status to 'sent' on
@@ -182,12 +203,11 @@ export const POST = withRouteContext(
)
const emailData = {
- invoice: invoice as Invoice,
+ invoice: renderableInvoice,
customer,
company: company as CompanySettings,
}
- const isCreditNote = !!invoice.credited_invoice_id
const docType = invoice.document_type || 'invoice'
let filename: string
if (isCreditNote) {
@@ -201,6 +221,78 @@ export const POST = withRouteContext(
}
const ccAddress = company.email || user.email
+ const partialFailures: Array<{ step: string; reason: string }> = []
+ if (paymentLinkFailure) {
+ partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
+ }
+
+ let statusFlipped = isCreditDeliveryRetry
+ let creditJournalEntryId: string | null = null
+
+ // Credit notes must be fully issued and booked before delivery. The CAS is
+ // the single-winner lock; the idempotent issue service can repair any
+ // immutable entry that committed before a later database step failed.
+ if (isCreditNote && originalInvoice) {
+ if (!isCreditDeliveryRetry) {
+ const { data: flipRows, error: updateError } = await supabase
+ .from('invoices')
+ .update({ status: 'sent' })
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .eq('status', 'draft')
+ .select('id')
+
+ if (updateError) {
+ opLog.error('credit note status update failed before issue', updateError)
+ return errorResponseFromCode('INVOICE_CREDIT_ISSUE_INCOMPLETE', opLog, {
+ requestId,
+ details: { failure_steps: ['status_update'] },
+ })
+ }
+ if (!flipRows || flipRows.length === 0) {
+ return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
+ requestId,
+ details: { currentStatus: 'sent' },
+ })
+ }
+ statusFlipped = true
+ }
+
+ const issueResult = await issueCreditNote({
+ supabase,
+ companyId: companyId!,
+ userId: user.id,
+ creditNote: invoice as CreditNote,
+ originalInvoice,
+ entityType: ((company as CompanySettings).entity_type as EntityType) || 'enskild_firma',
+ accountingMethod: ((company as Record).accounting_method || 'accrual') as AccountingMethod,
+ log: opLog,
+ })
+ creditJournalEntryId = issueResult.journalEntryId
+
+ if (!issueResult.complete) {
+ if (issueResult.journalEntryRequired && !issueResult.journalEntryId) {
+ await supabase
+ .from('invoices')
+ .update({ status: 'draft' })
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .eq('status', 'sent')
+ .is('journal_entry_id', null)
+ }
+ return errorResponseFromCode(
+ issueResult.repairRequired
+ ? 'INVOICE_CREDIT_REPAIR_REQUIRED'
+ : 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
+ opLog,
+ {
+ requestId,
+ details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
+ },
+ )
+ }
+ }
+
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress,
@@ -222,7 +314,7 @@ export const POST = withRouteContext(
opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown'))
return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, {
requestId,
- details: { providerError: result.error },
+ details: { retryable: true },
})
}
@@ -230,12 +322,6 @@ export const POST = withRouteContext(
// follow-up steps degrade the response to PARTIAL: the user gets a
// success toast with a sub-warning, and the audit trail records exactly
// which sub-step broke.
- const partialFailures: Array<{ step: string; reason: string }> = []
-
- if (paymentLinkFailure) {
- partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
- }
-
// Optimistic-locked flip (draft → sent). Two concurrent sends can both
// pass the draft guard above and both email the customer, but only the
// request that wins this compare-and-set runs the bookkeeping steps
@@ -245,8 +331,7 @@ export const POST = withRouteContext(
// A genuine update error also skips the follow-ups: the row is still
// 'draft', so a later retry re-runs the whole pipeline and ends with
// exactly one journal entry (at the cost of a duplicate email).
- let statusFlipped = false
- {
+ if (!isCreditNote) {
const { data: flipRows, error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
@@ -270,10 +355,10 @@ export const POST = withRouteContext(
}
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
- const accountingMethod = (company as Record).accounting_method as string | undefined
- let createdJournalEntryId: string | undefined
+ const accountingMethod = ((company as Record).accounting_method || 'accrual') as AccountingMethod
+ let createdJournalEntryId: string | undefined = creditJournalEntryId ?? undefined
- if (statusFlipped && isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
+ if (statusFlipped && !isCreditNote && isRealInvoice && accountingMethod === 'accrual') {
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
@@ -312,7 +397,7 @@ export const POST = withRouteContext(
opLog.error('failed to create invoice journal entry on send', err as Error)
partialFailures.push({
step: 'journal_entry',
- reason: err instanceof Error ? err.message : 'unknown',
+ reason: 'Fakturans verifikat kunde inte skapas.',
})
}
}
@@ -332,7 +417,7 @@ export const POST = withRouteContext(
opLog.error('failed to store invoice PDF as underlag', err as Error)
partialFailures.push({
step: 'pdf_archive',
- reason: err instanceof Error ? err.message : 'unknown',
+ reason: 'Fakturans PDF kunde inte arkiveras.',
})
}
}
@@ -340,7 +425,7 @@ export const POST = withRouteContext(
// Gated like the steps above: on a lost race the winning request emits
// it; on a flip error the row is still 'draft', so emitting would
// contradict DB state and the retry emits it instead.
- if (statusFlipped) {
+ if (statusFlipped && !isCreditNote) {
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
@@ -356,7 +441,7 @@ export const POST = withRouteContext(
return NextResponse.json({
success: true,
- message: `Fakturan har skickats till ${customer.email} (kopia till ${ccAddress})`,
+ message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`,
messageId: result.messageId,
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts
index b85cd361..8840a28d 100644
--- a/app/api/invoices/__tests__/route.test.ts
+++ b/app/api/invoices/__tests__/route.test.ts
@@ -41,12 +41,6 @@ vi.mock('@/lib/currency/riksbanken', () => ({
convertToSEK: vi.fn(),
}))
-const mockCreateCreditNoteJournalEntry = vi.fn()
-vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
- createCreditNoteJournalEntry: (...args: unknown[]) =>
- mockCreateCreditNoteJournalEntry(...args),
-}))
-
import { GET, POST } from '../route'
describe('GET /api/invoices', () => {
@@ -419,7 +413,7 @@ describe('POST /api/invoices (create credit note)', () => {
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
})
- it('creates credit note with negated amounts and emits event', async () => {
+ it('creates a credit note draft without booking or crediting the original', async () => {
const items = [
{
id: 'item-1',
@@ -449,25 +443,21 @@ describe('POST /api/invoices (create credit note)', () => {
subtotal: -10000,
vat_amount: -2500,
total: -12500,
- status: 'sent',
+ status: 'draft',
})
// Fetch original invoice
enqueue({ data: original, error: null })
+ // No existing credit-note draft
+ enqueue({ data: null, error: null })
// Insert credit note
enqueue({ data: creditNote, error: null })
// Insert credit note items
enqueue({ data: null, error: null })
- // Update original status to 'credited'
+ // Mark creation complete
enqueue({ data: null, error: null })
// Fetch complete credit note
enqueue({ data: { ...creditNote, items: [] }, error: null })
- // Fetch company settings for entity type
- enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
-
- mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'je-1' })
- // Update credit note with journal_entry_id
- enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
@@ -476,13 +466,35 @@ describe('POST /api/invoices (create credit note)', () => {
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
- const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
+ const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
- expect(body.data).toBeTruthy()
- expect(emitSpy).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'credit_note.created' })
- )
+ expect(body.data.status).toBe('draft')
+ expect(emitSpy).not.toHaveBeenCalled()
+ expect(mockSupabase.from).toHaveBeenCalledTimes(6)
+ })
+
+ it('returns an existing credit-note draft instead of creating a duplicate', async () => {
+ const original = makeInvoice({ id: VALID_UUID, status: 'sent' })
+ const existing = makeInvoice({
+ id: 'credit-existing',
+ invoice_number: 'KR-F-2024001',
+ status: 'draft',
+ credited_invoice_id: VALID_UUID,
+ })
+ enqueue({ data: original, error: null })
+ enqueue({ data: existing, error: null })
+
+ const request = createMockRequest('/api/invoices', {
+ method: 'POST',
+ body: { credited_invoice_id: VALID_UUID },
+ })
+ const response = await POST(request)
+ const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
+
+ expect(status).toBe(200)
+ expect(body.data.id).toBe('credit-existing')
+ expect(mockSupabase.from).toHaveBeenCalledTimes(2)
})
it('rolls back credit note when items insertion fails', async () => {
@@ -508,6 +520,7 @@ describe('POST /api/invoices (create credit note)', () => {
const creditNote = makeInvoice({ id: 'cn-1' })
enqueue({ data: original, error: null })
+ enqueue({ data: null, error: null })
enqueue({ data: creditNote, error: null })
// Items fail
enqueue({ data: null, error: { message: 'Items insert failed' } })
diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts
index 6dc39e2d..c81f96dc 100644
--- a/app/api/invoices/route.ts
+++ b/app/api/invoices/route.ts
@@ -3,11 +3,10 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas'
-import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
-import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
-import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
+import type { Invoice, InvoiceDocumentType, InvoiceItem } from '@/types'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
+import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Logger } from '@/lib/logger'
@@ -253,10 +252,6 @@ async function createCreditNote(
log: Logger,
requestId: string,
) {
- // Non-blocking issues (e.g. partial accrual cancellation) surfaced to the
- // caller alongside the created credit note.
- const warnings: Array<{ code: string; message: string }> = []
-
const { data: originalInvoice, error: originalError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
@@ -286,6 +281,59 @@ async function createCreditNote(
})
}
+ // Returning the existing credit note makes the action idempotent. A
+ // cancelled, unissued draft is reopened so the deterministic KR number can
+ // be reused without colliding with the company-wide invoice-number key.
+ const { data: existingCreditNote, error: existingCreditNoteError } = await supabase
+ .from('invoices')
+ .select('*, customer:customers(*), items:invoice_items(*)')
+ .eq('credited_invoice_id', input.credited_invoice_id)
+ .eq('company_id', companyId)
+ .eq('creation_complete', true)
+ .maybeSingle()
+
+ if (existingCreditNoteError) {
+ log.error('failed to check for an existing credit note', existingCreditNoteError)
+ return errorResponse(existingCreditNoteError, log, { requestId })
+ }
+ if (existingCreditNote) {
+ if (existingCreditNote.status === 'cancelled' && !existingCreditNote.journal_entry_id) {
+ const today = new Date().toISOString().split('T')[0]
+ const { error: reopenError } = await supabase
+ .from('invoices')
+ .update({
+ status: 'draft',
+ invoice_date: today,
+ due_date: today,
+ notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
+ updated_at: new Date().toISOString(),
+ })
+ .eq('id', existingCreditNote.id)
+ .eq('company_id', companyId)
+ .eq('status', 'cancelled')
+
+ if (reopenError) {
+ log.error('failed to reopen cancelled credit note draft', reopenError)
+ return errorResponse(reopenError, log, { requestId })
+ }
+
+ const { data: reopenedCreditNote, error: reopenedError } = await supabase
+ .from('invoices')
+ .select('*, customer:customers(*), items:invoice_items(*)')
+ .eq('id', existingCreditNote.id)
+ .eq('company_id', companyId)
+ .single()
+
+ if (reopenedError || !reopenedCreditNote) {
+ return errorResponse(reopenedError ?? new Error('Credit note draft not found'), log, {
+ requestId,
+ })
+ }
+ return NextResponse.json({ data: reopenedCreditNote })
+ }
+ return NextResponse.json({ data: existingCreditNote })
+ }
+
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
const { data: creditNote, error: creditNoteError } = await supabase
@@ -313,17 +361,33 @@ async function createCreditNote(
reverse_charge_text: originalInvoice.reverse_charge_text,
your_reference: originalInvoice.your_reference,
our_reference: originalInvoice.our_reference,
+ deduction_total: originalInvoice.deduction_total
+ ? -Math.abs(originalInvoice.deduction_total)
+ : 0,
+ deduction_personnummer_encrypted: originalInvoice.deduction_personnummer_encrypted ?? null,
+ deduction_personnummer_last4: originalInvoice.deduction_personnummer_last4 ?? null,
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
credited_invoice_id: input.credited_invoice_id,
// Copy the original's dimension bag so the credit-note verifikat nets
// against the same dimension cells in reports (dimensions PR7).
default_dimensions: originalInvoice.default_dimensions ?? {},
- status: 'sent',
+ status: 'draft',
+ creation_complete: false,
})
.select()
.single()
if (creditNoteError) {
+ if (creditNoteError.code === '23505') {
+ const { data: racedCreditNote } = await supabase
+ .from('invoices')
+ .select('*, customer:customers(*), items:invoice_items(*)')
+ .eq('credited_invoice_id', input.credited_invoice_id)
+ .eq('company_id', companyId)
+ .eq('creation_complete', true)
+ .maybeSingle()
+ if (racedCreditNote) return NextResponse.json({ data: racedCreditNote })
+ }
log.error('credit note insert failed', creditNoteError)
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
requestId,
@@ -331,40 +395,24 @@ async function createCreditNote(
})
}
- const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null; dimensions?: Record }) => ({
- invoice_id: creditNote.id,
- sort_order: item.sort_order,
- line_type: item.line_type ?? 'product',
- description: item.description,
- quantity: -Math.abs(item.quantity),
- unit: item.unit,
- unit_price: item.unit_price,
- line_total: -Math.abs(item.line_total),
- vat_rate: item.vat_rate ?? 0,
- vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
- // Carry the original's per-line revenue-account override so the reversal
- // hits the SAME account it originally credited (e.g. 3041, not the
- // VAT-derived 3001): otherwise the override account keeps a dangling
- // balance. article_id is preserved for the usage history.
- revenue_account: item.revenue_account ?? null,
- article_id: item.article_id ?? null,
- // Same reasoning for periodiserade lines: the credit-note verifikat must
- // reverse against the 29xx interim account the original credited, not the
- // revenue account. generatePerRateLines reads these fields to substitute.
- // No schedule is ever created for a credit note (only send/mark-sent
- // create schedules); the original's schedule is cancelled below.
- accrual_period_start: item.accrual_period_start ?? null,
- accrual_period_end: item.accrual_period_end ?? null,
- accrual_balance_account: item.accrual_balance_account ?? null,
- // Same reasoning as revenue_account: the reversal must carry the exact
- // per-item bag the original booked with (dimensions PR7).
- dimensions: item.dimensions ?? {},
- }))
+ const creditNoteItems = (originalInvoice.items || []).map((item: InvoiceItem) =>
+ buildCreditNoteItem(creditNote.id, item)
+ )
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
if (itemsError) {
- await supabase.from('invoices').delete().eq('id', creditNote.id)
+ const { error: cleanupError } = await supabase
+ .from('invoices')
+ .delete()
+ .eq('id', creditNote.id)
+ .eq('company_id', companyId)
+ .eq('creation_complete', false)
+ if (cleanupError) {
+ log.error('failed to clean up incomplete credit note', cleanupError, {
+ creditNoteId: creditNote.id,
+ })
+ }
log.error('credit note items insert failed; rolled back', itemsError, {
creditNoteId: creditNote.id,
})
@@ -374,91 +422,35 @@ async function createCreditNote(
})
}
- await supabase
+ const { error: completionError } = await supabase
.from('invoices')
- .update({ status: 'credited' })
- .eq('id', input.credited_invoice_id)
+ .update({ creation_complete: true, updated_at: new Date().toISOString() })
+ .eq('id', creditNote.id)
+ .eq('company_id', companyId)
+ .eq('creation_complete', false)
- const { data: completeCreditNote } = await supabase
+ if (completionError) {
+ log.error('failed to mark credit note creation complete', completionError, {
+ creditNoteId: creditNote.id,
+ })
+ return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId })
+ }
+
+ const { data: completeCreditNote, error: completeCreditNoteError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', creditNote.id)
- .single()
-
- const { data: creditNoteSettings } = await supabase
- .from('company_settings')
- .select('entity_type, accounting_method')
.eq('company_id', companyId)
+ .eq('creation_complete', true)
.single()
- const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
- const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual'
-
- // Cash method skips: there's no original invoice JE to reverse, recognition
- // is deferred until refund.
- if (completeCreditNote && accountingMethod === 'accrual') {
- try {
- const journalEntry = await createCreditNoteJournalEntry(
- supabase,
- companyId,
- userId,
- completeCreditNote as Invoice,
- entityType,
- completeCreditNote.customer?.name,
- )
- if (journalEntry) {
- await supabase
- .from('invoices')
- .update({ journal_entry_id: journalEntry.id })
- .eq('id', creditNote.id)
- }
- } catch (err) {
- log.error('failed to create credit note journal entry', err as Error, {
- creditNoteId: creditNote.id,
- })
- // Non-blocking: credit note still exists.
- }
-
- // Periodisering interplay: cancel remaining months and storno posted
- // dissolutions so origin + dissolutions + stornos + credit net to zero on
- // both 29xx and 3xxx. Best-effort: never blocks the credit itself, but
- // partial reversals are surfaced as a response warning so the user knows
- // the schedule stayed active.
- try {
- const cancelResult = await cancelSchedulesForSource(
- supabase,
- companyId,
- userId,
- { invoiceId: input.credited_invoice_id },
- { reversalDate: creditNote.invoice_date },
- )
- if (cancelResult.failedReversals > 0) {
- warnings.push({
- code: 'ACCRUAL_CANCEL_PARTIAL',
- message:
- 'Fakturan krediterades, men en eller flera periodiseringsverifikat ' +
- 'kunde inte vändas. Periodiseringen är fortfarande aktiv: ' +
- 'kontrollera under Bokföring → Periodiseringar.',
- })
- }
- } catch (err) {
- log.warn('failed to cancel accrual schedules for credited invoice', err as Error)
- warnings.push({
- code: 'ACCRUAL_CANCEL_PARTIAL',
- message:
- 'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' +
- 'Kontrollera under Bokföring → Periodiseringar.',
- })
- }
-
- await eventBus.emit({
- type: 'credit_note.created',
- payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
- })
+ if (completeCreditNoteError || !completeCreditNote) {
+ log.error('failed to read completed credit note', completeCreditNoteError)
+ return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId })
}
- return NextResponse.json({
- data: completeCreditNote,
- ...(warnings.length > 0 ? { warnings } : {}),
- })
+ // A credit note is only issued when the user sends it or marks it as sent.
+ // Until then it is a non-editable draft: no journal entry is created and
+ // the original invoice remains in its current state.
+ return NextResponse.json({ data: completeCreditNote })
}
diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts
index 0e8206e4..4c7f0efb 100644
--- a/app/api/settings/__tests__/route.test.ts
+++ b/app/api/settings/__tests__/route.test.ts
@@ -84,6 +84,88 @@ describe('PUT /api/settings', () => {
expect(body.data.company_name).toBe('New Name')
})
+ it('updates all three reminder thresholds', async () => {
+ enqueueMany([
+ {
+ data: {
+ entity_type: 'aktiebolag',
+ onboarding_complete: true,
+ reminder_days_level_1: 15,
+ reminder_days_level_2: 30,
+ reminder_days_level_3: 45,
+ },
+ },
+ {
+ data: {
+ id: 's1',
+ reminder_days_level_1: 7,
+ reminder_days_level_2: 21,
+ reminder_days_level_3: 35,
+ },
+ },
+ ])
+
+ const request = createMockRequest('/api/settings', {
+ method: 'PUT',
+ body: {
+ reminder_days_level_1: 7,
+ reminder_days_level_2: 21,
+ reminder_days_level_3: 35,
+ },
+ })
+ const response = await PUT(request, { params: Promise.resolve({}) })
+ const { status, body } = await parseJsonResponse<{
+ data: { reminder_days_level_1: number; reminder_days_level_2: number; reminder_days_level_3: number }
+ }>(response)
+
+ expect(status).toBe(200)
+ expect(body.data).toMatchObject({
+ reminder_days_level_1: 7,
+ reminder_days_level_2: 21,
+ reminder_days_level_3: 35,
+ })
+ })
+
+ it('returns 400 when reminder thresholds are not increasing', async () => {
+ enqueue({
+ data: {
+ reminder_days_level_1: 15,
+ reminder_days_level_2: 30,
+ reminder_days_level_3: 45,
+ },
+ })
+
+ const request = createMockRequest('/api/settings', {
+ method: 'PUT',
+ body: {
+ reminder_days_level_1: 30,
+ reminder_days_level_2: 20,
+ reminder_days_level_3: 45,
+ },
+ })
+ const response = await PUT(request, { params: Promise.resolve({}) })
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(400)
+ expect(supabase.from).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns 404 when the settings row does not exist', async () => {
+ enqueueMany([
+ { data: { onboarding_complete: false } },
+ { data: null, error: { code: 'PGRST116', message: 'No rows returned' } },
+ ])
+
+ const request = createMockRequest('/api/settings', {
+ method: 'PUT',
+ body: { reminder_days_level_1: 10 },
+ })
+ const response = await PUT(request, { params: Promise.resolve({}) })
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(404)
+ })
+
it('blocks a vacation-year basis change while open balances exist', async () => {
enqueueMany([
{ data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
index 2d9aee91..40864825 100644
--- a/app/api/settings/route.ts
+++ b/app/api/settings/route.ts
@@ -40,7 +40,7 @@ export const PUT = withRouteContext(
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
- .select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis')
+ .select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
.eq('company_id', companyId)
.single()
@@ -48,6 +48,18 @@ export const PUT = withRouteContext(
if (!validation.success) return validation.response
const body = validation.data
+ const reminderDays = [
+ body.reminder_days_level_1 ?? oldSettings?.reminder_days_level_1 ?? 15,
+ body.reminder_days_level_2 ?? oldSettings?.reminder_days_level_2 ?? 30,
+ body.reminder_days_level_3 ?? oldSettings?.reminder_days_level_3 ?? 45,
+ ]
+ if (!(reminderDays[0] < reminderDays[1] && reminderDays[1] < reminderDays[2])) {
+ return NextResponse.json(
+ { error: 'Påminnelsedagarna måste ligga i stigande ordning.' },
+ { status: 400 },
+ )
+ }
+
// Lock org_number after onboarding is complete (legal identifier: changing it
// would orphan vouchers, SIE history, and tax filings). company_name remains
// editable so users can update their display/brand name (e.g. särskilt företagsnamn).
@@ -121,6 +133,9 @@ export const PUT = withRouteContext(
.single()
if (error) {
+ if (error.code === 'PGRST116') {
+ return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
+ }
return NextResponse.json({ error: error.message }, { status: 500 })
}
diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts
index 9103b7e1..79c95d0d 100644
--- a/app/api/supplier-invoices/__tests__/route.test.ts
+++ b/app/api/supplier-invoices/__tests__/route.test.ts
@@ -39,6 +39,11 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
}))
+const mockLinkToJournalEntry = vi.fn()
+vi.mock('@/lib/core/documents/document-service', () => ({
+ linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
+}))
+
import { eventBus } from '@/lib/events'
import { GET, POST } from '../route'
@@ -128,6 +133,7 @@ describe('GET /api/supplier-invoices', () => {
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
+const DOCUMENT_UUID = '550e8400-e29b-41d4-a716-446655440002'
describe('POST /api/supplier-invoices', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -221,6 +227,77 @@ describe('POST /api/supplier-invoices', () => {
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
})
+ it('stores an uploaded document and links it to the registration entry', async () => {
+ const supplier = makeSupplier({ id: VALID_UUID })
+ const createdInvoice = makeSupplierInvoice({ id: 'si-with-document', document_id: DOCUMENT_UUID })
+
+ enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null })
+ enqueue({ data: null, error: null })
+ enqueue({ data: supplier, error: null })
+ enqueue({ data: 6 })
+ enqueue({ data: createdInvoice, error: null })
+ enqueue({ data: null, error: null })
+ enqueue({ data: { accounting_method: 'accrual' }, error: null })
+ mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-document' })
+ enqueue({ data: null, error: null })
+ mockLinkToJournalEntry.mockResolvedValue({ id: DOCUMENT_UUID })
+
+ const request = createMockRequest('/api/supplier-invoices', {
+ method: 'POST',
+ body: {
+ supplier_id: VALID_UUID,
+ document_id: DOCUMENT_UUID,
+ supplier_invoice_number: 'LF-DOCUMENT',
+ invoice_date: '2024-06-01',
+ due_date: '2024-07-01',
+ items: [
+ { description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
+ ],
+ },
+ })
+
+ const response = await POST(request)
+ const { status, body } = await parseJsonResponse<{
+ data: { document_id: string; registration_journal_entry_id: string }
+ }>(response)
+
+ expect(status).toBe(200)
+ expect(body.data.document_id).toBe(DOCUMENT_UUID)
+ expect(body.data.registration_journal_entry_id).toBe('je-document')
+ expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
+ mockSupabase,
+ 'company-1',
+ DOCUMENT_UUID,
+ 'je-document',
+ )
+ })
+
+ it('rejects a document that is missing or outside the active company', async () => {
+ enqueue({ data: null, error: null })
+
+ const request = createMockRequest('/api/supplier-invoices', {
+ method: 'POST',
+ body: {
+ supplier_id: VALID_UUID,
+ document_id: DOCUMENT_UUID,
+ supplier_invoice_number: 'LF-INVALID-DOCUMENT',
+ invoice_date: '2024-06-01',
+ due_date: '2024-07-01',
+ items: [
+ { description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
+ ],
+ },
+ })
+
+ const response = await POST(request)
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
+
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
+ expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
+ expect(mockLinkToJournalEntry).not.toHaveBeenCalled()
+ })
+
it('emits supplier_invoice.registered event', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts
index 0a9e1975..3ec706e0 100644
--- a/app/api/supplier-invoices/route.ts
+++ b/app/api/supplier-invoices/route.ts
@@ -12,6 +12,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
ensureInitialized()
@@ -68,6 +69,42 @@ export const POST = withRouteContext(
const body = validation.data
const paidPrivately = body.paid_with_private_funds === true
+ if (body.document_id) {
+ const { data: document, error: documentError } = await supabase
+ .from('document_attachments')
+ .select('id, journal_entry_id')
+ .eq('id', body.document_id)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (documentError || !document || document.journal_entry_id) {
+ return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
+ requestId,
+ details: { reason: 'document_id is missing, belongs to another company, or is already linked' },
+ })
+ }
+
+ const { data: existingDocumentUse, error: existingDocumentUseError } = await supabase
+ .from('supplier_invoices')
+ .select('id')
+ .eq('company_id', companyId)
+ .eq('document_id', body.document_id)
+ .limit(1)
+ .maybeSingle()
+
+ if (existingDocumentUseError) {
+ log.error('supplier invoice document usage lookup failed', existingDocumentUseError)
+ return errorResponse(existingDocumentUseError, log, { requestId })
+ }
+
+ if (existingDocumentUse) {
+ return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
+ requestId,
+ details: { reason: 'document_id is already used by a supplier invoice' },
+ })
+ }
+ }
+
if (paidPrivately && body.reverse_charge) {
// RC invoices come from registered businesses with formal invoices and
// go through normal AP. "Privately paid" only makes sense for
@@ -236,6 +273,7 @@ export const POST = withRouteContext(
user_id: user.id,
company_id: companyId,
supplier_id: body.supplier_id,
+ document_id: body.document_id || null,
arrival_number: arrivalNum,
supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date,
@@ -502,6 +540,28 @@ export const POST = withRouteContext(
}
}
+ const primaryJournalEntryId = paymentJournalEntryId || registrationJournalEntryId
+ if (body.document_id && primaryJournalEntryId) {
+ try {
+ await linkToJournalEntry(
+ supabase,
+ companyId,
+ body.document_id,
+ primaryJournalEntryId,
+ )
+ } catch (err) {
+ log.warn('supplier invoice document could not be linked to journal entry', {
+ documentId: body.document_id,
+ journalEntryId: primaryJournalEntryId,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ warnings.push({
+ code: 'DOCUMENT_LINK_FAILED',
+ message: 'Fakturan registrerades, men underlaget kunde inte kopplas till verifikationen.',
+ })
+ }
+ }
+
try {
await eventBus.emit({
type: 'supplier_invoice.registered',
diff --git a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts
index 6080280b..7728601d 100644
--- a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts
+++ b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts
@@ -309,6 +309,43 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => {
expect(body.error.code).toBe('LINK_TX_INVOICE_NOT_OPEN')
})
+ it('returns 400 before linking when supplied invoice is a credit note', async () => {
+ enqueue({
+ data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000 }),
+ error: null,
+ })
+ enqueue({
+ data: {
+ id: JE_UUID,
+ status: 'posted',
+ voucher_series: 'A',
+ voucher_number: 1,
+ entry_date: '2026-05-15',
+ },
+ error: null,
+ })
+ enqueue({
+ data: makeInvoice({
+ id: INV_UUID,
+ status: 'sent',
+ total: -1000,
+ remaining_amount: -1000,
+ credited_invoice_id: 'original-invoice-1',
+ }),
+ error: null,
+ })
+
+ const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
+ method: 'POST',
+ body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID },
+ })
+ const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
+
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('LINK_TX_INVOICE_CREDIT_NOTE')
+ })
+
it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses and rolls back the tx link', async () => {
enqueue({
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }),
diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts
index 2e614862..0925068d 100644
--- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts
+++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts
@@ -196,6 +196,51 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_INVOICE_TYPE')
})
+ it('rejects matching an original invoice with an active credit-note draft', async () => {
+ const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
+ const invoice = {
+ ...makeInvoice({ id: VALID_UUID, status: 'sent', credited_invoice_id: null }),
+ credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }],
+ }
+ enqueue({ data: tx, error: null })
+ enqueue({ data: invoice, error: null })
+
+ const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
+ method: 'POST',
+ body: { invoice_id: VALID_UUID },
+ })
+ const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
+ const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
+
+ expect(response.status).toBe(400)
+ expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
+ expect(mockCreateJournalEntry).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 before booking when matching against a credit note', async () => {
+ const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
+ const creditNote = makeInvoice({
+ id: VALID_UUID,
+ status: 'sent',
+ total: -12500,
+ credited_invoice_id: 'original-invoice-1',
+ })
+ enqueue({ data: tx, error: null })
+ enqueue({ data: creditNote, error: null })
+
+ const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
+ method: 'POST',
+ body: { invoice_id: VALID_UUID },
+ })
+ const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
+
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
+ expect(mockCreateJournalEntry).not.toHaveBeenCalled()
+ expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
+ })
+
it('returns 400 when invoice is not in unpaid state', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' })
diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts
index f82c0f49..8e197bcf 100644
--- a/app/api/transactions/[id]/match-invoice/route.ts
+++ b/app/api/transactions/[id]/match-invoice/route.ts
@@ -82,7 +82,7 @@ export const POST = withRouteContext(
const { data: invoice, error: fetchInvError } = await supabase
.from('invoices')
- .select('*, customer:customers(*), items:invoice_items(*)')
+ .select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
.eq('id', invoice_id)
.eq('company_id', companyId)
.single()
@@ -104,6 +104,23 @@ export const POST = withRouteContext(
})
}
+ if (invoice.credited_invoice_id) {
+ return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, { requestId })
+ }
+
+ const activeCreditNotes = ((invoice as { credit_notes?: Array<{
+ status: string
+ creation_complete?: boolean
+ }> }).credit_notes ?? []).filter(
+ (creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
+ )
+ if (activeCreditNotes.length > 0) {
+ return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, {
+ requestId,
+ details: { reason: 'active_credit_note' },
+ })
+ }
+
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
return errorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, {
requestId,
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts
index fc79650d..c1aed7ee 100644
--- a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts
@@ -571,6 +571,47 @@ describe('POST :id/match-invoice', () => {
expect((await res.json()).error.code).toBe('MATCH_INVOICE_TX_ALREADY_LINKED')
})
+ it('rejects a credit note before creating a payment journal entry', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: {
+ data: {
+ id: TX_ID,
+ amount: 12500,
+ date: '2026-05-12',
+ currency: 'SEK',
+ invoice_id: null,
+ },
+ error: null,
+ },
+ invoices: {
+ data: {
+ id: INV_ID,
+ status: 'sent',
+ document_type: 'invoice',
+ total: -12500,
+ credited_invoice_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
+ },
+ error: null,
+ },
+ }),
+ )
+
+ const res = await matchInvoicePOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
+ { invoice_id: INV_ID },
+ ),
+ txParams(TX_ID),
+ )
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
+ expect(createInvPmtJE).not.toHaveBeenCalled()
+ expect(createInvCashJE).not.toHaveBeenCalled()
+ })
+
// The v1 route threads resolveSettlementAccount(transaction.cash_account_id)
// exactly like the dashboard route and the agent/MCP commit path; these
// regression tests were missing here (flagged in triage on #987) even
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts
index 5487b2ca..19293cc2 100644
--- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts
@@ -180,6 +180,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
details: { documentType: docType },
})
}
+ if (invoice.credited_invoice_id) {
+ return v1ErrorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
if (
invoice.status !== 'sent' &&
invoice.status !== 'overdue' &&
diff --git a/components/bookkeeping/DocumentUploadZone.tsx b/components/bookkeeping/DocumentUploadZone.tsx
index a9b483cd..e5aea4ca 100644
--- a/components/bookkeeping/DocumentUploadZone.tsx
+++ b/components/bookkeeping/DocumentUploadZone.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback, useRef } from 'react'
+import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Upload, FileText, ImageIcon, X, Loader2 } from 'lucide-react'
@@ -45,10 +46,13 @@ function isImageType(type: string): boolean {
* returned by /api/documents. Falls back to message_en or null if the
* shape is unexpected.
*/
-function extractErrorMessage(err: unknown): string | null {
+function extractErrorMessage(err: unknown, locale: string): string | null {
if (typeof err === 'string') return err
if (err && typeof err === 'object') {
const e = err as { message?: unknown; message_en?: unknown; code?: unknown }
+ if (locale === 'en' && typeof e.message_en === 'string' && e.message_en.length > 0) {
+ return e.message_en
+ }
if (typeof e.message === 'string' && e.message.length > 0) return e.message
if (typeof e.message_en === 'string' && e.message_en.length > 0) return e.message_en
if (typeof e.code === 'string') return e.code
@@ -64,6 +68,8 @@ export default function DocumentUploadZone({
disabled = false,
compact = false,
}: DocumentUploadZoneProps) {
+ const t = useTranslations('document_upload')
+ const locale = useLocale()
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef(null)
@@ -91,13 +97,13 @@ export default function DocumentUploadZone({
fileName: file.fileName,
})
const reason = res.status === 401 || res.status === 403
- ? 'Din session har gått ut. Ladda om sidan och logga in igen.'
- : `Servern svarade ${res.status}.`
+ ? t('session_expired')
+ : t('server_status', { status: res.status })
return { ...file, status: 'error', error: reason }
}
if (!res.ok || result.error) {
- const errMessage = extractErrorMessage(result.error) || `Uppladdning misslyckades (${res.status})`
+ const errMessage = extractErrorMessage(result.error, locale) || t('failed_status', { status: res.status })
console.warn('[DocumentUploadZone] Upload error', {
status: res.status,
error: result.error,
@@ -112,9 +118,9 @@ export default function DocumentUploadZone({
error: err,
fileName: file.fileName,
})
- return { ...file, status: 'error', error: 'Uppladdning misslyckades: nätverksfel' }
+ return { ...file, status: 'error', error: t('network_error') }
}
- }, [journalEntryId])
+ }, [journalEntryId, locale, t])
const handleFiles = useCallback(async (newFiles: File[]) => {
const remaining = maxFiles - files.length
@@ -127,7 +133,7 @@ export default function DocumentUploadZone({
validFiles.push({
file,
status: 'error',
- error: 'Filtypen stöds inte',
+ error: t('unsupported_type'),
fileName: file.name,
fileSize: file.size,
uploadKey: `upload-${++uploadCounter}`,
@@ -138,7 +144,7 @@ export default function DocumentUploadZone({
validFiles.push({
file,
status: 'error',
- error: 'Filen är för stor (max 10 MB)',
+ error: t('too_large'),
fileName: file.name,
fileSize: file.size,
uploadKey: `upload-${++uploadCounter}`,
@@ -165,7 +171,7 @@ export default function DocumentUploadZone({
)
onFilesChange([...currentFiles])
}
- }, [files, maxFiles, onFilesChange, uploadFile])
+ }, [files, maxFiles, onFilesChange, t, uploadFile])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
@@ -230,11 +236,11 @@ export default function DocumentUploadZone({
- {compact ? 'Dra och släpp eller klicka' : 'Dra och släpp filer här'}
+ {compact ? t('compact_prompt') : t('prompt')}
{!compact && (
- PDF, bilder (max 10 MB)
+ {t('format_hint')}
)}
@@ -264,13 +270,13 @@ export default function DocumentUploadZone({
)}
{file.status === 'uploaded' && (
- Uppladdad
+ {t('uploaded')}
)}
{file.status === 'error' && (
<>
- Fel
+ {t('error')}
{file.error && (
{file.error}
@@ -281,7 +287,7 @@ export default function DocumentUploadZone({
{
e.stopPropagation()
@@ -297,7 +303,7 @@ export default function DocumentUploadZone({
)}
{isUploading && (
- Laddar upp...
+ {t('uploading')}
)}
)
diff --git a/components/bookkeeping/JournalEntryAttachments.tsx b/components/bookkeeping/JournalEntryAttachments.tsx
index b843fba0..88766da9 100644
--- a/components/bookkeeping/JournalEntryAttachments.tsx
+++ b/components/bookkeeping/JournalEntryAttachments.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
@@ -38,6 +39,7 @@ interface DocumentRecord {
storage_path: string
created_at: string
download_url?: string
+ referenced?: boolean
}
interface JournalEntryAttachmentsProps {
@@ -90,12 +92,45 @@ export default function JournalEntryAttachments({
const fetchDocuments = useCallback(async () => {
try {
- const res = await fetch(
- `/api/documents?journal_entry_id=${journalEntryId}¤t_only=true`
- )
- const { data } = await res.json()
- setDocuments(data || [])
- onCountChangeRef.current?.(data?.length || 0)
+ const [documentsRes, referencesRes] = await Promise.all([
+ fetch(`/api/documents?journal_entry_id=${journalEntryId}¤t_only=true`),
+ fetch(`/api/bookkeeping/journal-entries/${journalEntryId}/references`),
+ ])
+ const { data: directDocuments } = await documentsRes.json()
+ const direct = (directDocuments || []) as DocumentRecord[]
+ const directIds = new Set(direct.map((document) => document.id))
+
+ let referenced: DocumentRecord[] = []
+ if (referencesRes.ok) {
+ const { data: referenceData } = await referencesRes.json()
+ const documentIds = Array.from(new Set(
+ (referenceData?.references || [])
+ .map((reference: { document_id?: string }) => reference.document_id)
+ .filter((documentId: string | undefined): documentId is string => (
+ Boolean(documentId) && !directIds.has(documentId as string)
+ )),
+ ))
+
+ const referencedDocuments = await Promise.all(
+ documentIds.map(async (documentId) => {
+ try {
+ const response = await fetch(`/api/documents/${documentId}`)
+ if (!response.ok) return null
+ const { data } = await response.json()
+ return data ? { ...data, referenced: true } as DocumentRecord : null
+ } catch {
+ return null
+ }
+ }),
+ )
+ referenced = referencedDocuments.filter(
+ (document): document is DocumentRecord => document !== null,
+ )
+ }
+
+ const allDocuments = [...direct, ...referenced]
+ setDocuments(allDocuments)
+ onCountChangeRef.current?.(allDocuments.length)
} catch {
// Non-critical: silently ignore
} finally {
@@ -286,36 +321,45 @@ export default function JournalEntryAttachments({
)}
{doc.file_name}
+ {doc.referenced && (
+
+ {t('via_supplier_invoice')}
+
+ )}
{formatFileSize(doc.file_size_bytes)}
- handleOpenReplacePicker(doc.id)}
- disabled={isReplacing}
- title={t('replace')}
- aria-label={t('replace')}
- >
- {isReplacing ? (
-
- ) : (
-
- )}
-
+ {!doc.referenced && (
+ <>
+ handleOpenReplacePicker(doc.id)}
+ disabled={isReplacing}
+ title={t('replace')}
+ aria-label={t('replace')}
+ >
+ {isReplacing ? (
+
+ ) : (
+
+ )}
+
- handleRequestRemove(doc)}
- title={t('remove')}
- aria-label={t('remove')}
- >
-
-
+ handleRequestRemove(doc)}
+ title={t('remove')}
+ aria-label={t('remove')}
+ >
+
+
+ >
+ )}
{
- onSubmit({
+ const payload: CreateCustomerInput = {
...data,
email: data.email || undefined,
- personal_number: data.personal_number || undefined,
- })
+ personal_number: data.personal_number || null,
+ }
+ if (data.personal_number?.startsWith('*') && data.personal_number === initialData?.personal_number) {
+ delete payload.personal_number
+ }
+ onSubmit(payload)
}
return (
diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx
index 8b55b9d6..93fa0fdf 100644
--- a/components/invoices/InvoiceEditor.tsx
+++ b/components/invoices/InvoiceEditor.tsx
@@ -25,7 +25,7 @@ import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
-import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags } from 'lucide-react'
+import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Copy } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -60,6 +60,7 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
+import type { InvoiceCopyInitial } from '@/lib/invoices/copy-invoice'
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } from '@/types'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
@@ -77,6 +78,7 @@ export type InvoiceForEdit = Invoice & { items: InvoiceItem[] }
export type InvoiceEditorProps = (
| { mode?: 'create' }
| { mode: 'edit'; initial: InvoiceForEdit }
+ | { mode: 'copy'; initial: InvoiceCopyInitial }
) & { bare?: boolean }
// Subset of Article fields the line picker needs to pre-fill a row.
@@ -106,7 +108,10 @@ function compactDims(dims: Record): string {
export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'create' }) {
// Edit mode pre-fills the form from an existing draft and saves via PATCH.
const isEditMode = props.mode === 'edit'
+ const isCopyMode = props.mode === 'copy'
const initial = props.mode === 'edit' ? props.initial : null
+ const copyInitial = props.mode === 'copy' ? props.initial : null
+ const initialOreRounding = initial?.ore_rounding ?? copyInitial?.ore_rounding
const bare = props.bare === true
const router = useRouter()
const { toast } = useToast()
@@ -271,7 +276,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// Öresavrundning is display-only. In edit mode the draft's stored flag wins;
// otherwise it defaults to the company-wide setting (loaded below).
const [oreRounding, setOreRounding] = useState(
- typeof initial?.ore_rounding === 'boolean' ? initial.ore_rounding : true,
+ typeof initialOreRounding === 'boolean' ? initialOreRounding : true,
)
const [vatRegistered, setVatRegistered] = useState(true)
const [numberPreview, setNumberPreview] = useState(null)
@@ -291,7 +296,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// open/close bookkeeping as accountOverrideRows).
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [defaultDims, setDefaultDims] = useState>(
- initial?.default_dimensions ?? {},
+ initial?.default_dimensions ?? copyInitial?.default_dimensions ?? {},
)
const [dimensionOverrideRows, setDimensionOverrideRows] = useState>(new Set())
// True only when the user had zero invoices when this page loaded. The
@@ -362,6 +367,26 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
dimensions: hasDimensionValues(item.dimensions) ? item.dimensions ?? null : null,
})),
}
+ : copyInitial
+ ? {
+ customer_id: copyInitial.customer_id,
+ invoice_date: '',
+ due_date: '',
+ delivery_date: '',
+ currency: copyInitial.currency,
+ document_type: 'invoice' as InvoiceDocumentType,
+ your_reference: '',
+ our_reference: copyInitial.our_reference,
+ notes: copyInitial.notes,
+ payment_link_url: '',
+ payment_link_auto: true,
+ external_invoice_number: '',
+ self_billing_agreement_ref: '',
+ received_date: '',
+ deduction_personnummer: '',
+ deduction_housing_designation: '',
+ items: copyInitial.items,
+ }
: {
customer_id: '',
invoice_date: '',
@@ -542,11 +567,13 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
.single()
if (data?.invoice_default_notes) {
setDefaultNotes(data.invoice_default_notes)
- setValue('notes', data.invoice_default_notes)
+ if (!isEditMode && !isCopyMode) {
+ setValue('notes', data.invoice_default_notes)
+ }
}
// Pre-fill "Vår referens" from the company default: only when creating a
// fresh invoice, so an edited draft's own reference is never overwritten.
- if (!isEditMode && data?.default_our_reference) {
+ if (!isEditMode && !isCopyMode && data?.default_our_reference) {
setValue('our_reference', data.default_our_reference)
}
setHasBankDetails(
@@ -557,7 +584,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
// An explicit per-invoice flag (edit mode) wins; only fall back to the
// company-wide setting when creating or when the draft never set one.
- if (typeof data?.ore_rounding === 'boolean' && (!isEditMode || initial?.ore_rounding == null)) {
+ if (typeof data?.ore_rounding === 'boolean' && initialOreRounding == null) {
setOreRounding(data.ore_rounding)
}
setLogoUrl(data?.logo_url ?? null)
@@ -1266,6 +1293,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const titleText = isEditMode
? t('title_edit')
+ : isCopyMode
+ ? t('title_copy')
: isSelfBilled
? ts('title')
: watchDocumentType === 'proforma'
@@ -1275,6 +1304,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
: t('title_invoice')
const subtitleText = isEditMode
? t('subtitle_edit')
+ : isCopyMode
+ ? t('subtitle_copy')
: isSelfBilled
? ts('subtitle')
: watchDocumentType === 'proforma'
@@ -1314,7 +1345,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
/>
- {!isEditMode && (
+ {isCopyMode && copyInitial && (
+
+
+
+ {t('copy_notice', { number: copyInitial.source_invoice_number })}
+
+
+ )}
+
+ {!isEditMode && !isCopyMode && (
setMode(v as 'invoice' | 'self_billed')}>
{t('mode_invoice')}
diff --git a/components/invoices/NewInvoiceDialog.tsx b/components/invoices/NewInvoiceDialog.tsx
index 899db82f..c276b4b0 100644
--- a/components/invoices/NewInvoiceDialog.tsx
+++ b/components/invoices/NewInvoiceDialog.tsx
@@ -1,12 +1,23 @@
'use client'
import dynamic from 'next/dynamic'
+import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
+import { Button } from '@/components/ui/button'
+import { createClient } from '@/lib/supabase/client'
+import { useCompany } from '@/contexts/CompanyContext'
+import { fetchAllRows } from '@/lib/supabase/fetch-all'
+import {
+ buildInvoiceCopyInitial,
+ canCopyInvoice,
+ type InvoiceCopyInitial,
+ type InvoiceCopySource,
+} from '@/lib/invoices/copy-invoice'
// Deferred: the editor (and its framer-motion dependency) is a large chunk
-// that would otherwise ship with the invoice LIST bundle — it's only needed
+// that would otherwise ship with the invoice LIST bundle: it is only needed
// once this dialog actually opens.
const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor'), {
ssr: false,
@@ -22,6 +33,7 @@ const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
+ copyFromId?: string | null
}
/**
@@ -35,8 +47,70 @@ interface Props {
* shows the invoice-number preview: a static DialogTitle would duplicate or
* contradict it.
*/
-export default function NewInvoiceDialog({ open, onOpenChange }: Props) {
+export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null }: Props) {
const t = useTranslations('invoice_editor')
+ const { company } = useCompany()
+ const supabase = useMemo(() => createClient(), [])
+ const [copyLoad, setCopyLoad] = useState<{
+ sourceId: string | null
+ initial: InvoiceCopyInitial | null
+ failed: boolean
+ }>({ sourceId: null, initial: null, failed: false })
+
+ useEffect(() => {
+ if (!open || !copyFromId) return
+ if (!company?.id) return
+
+ let cancelled = false
+
+ const loadCopySource = async () => {
+ const { data, error } = await supabase
+ .from('invoices')
+ .select('*')
+ .eq('id', copyFromId)
+ .eq('company_id', company.id)
+ .single()
+
+ let items: Record[] = []
+ if (!error && data) {
+ try {
+ items = await fetchAllRows>(({ from, to }) =>
+ supabase
+ .from('invoice_items')
+ .select('*')
+ .eq('invoice_id', copyFromId)
+ .eq('company_id', company.id)
+ .order('id', { ascending: true })
+ .range(from, to),
+ )
+ } catch {
+ if (!cancelled) setCopyLoad({ sourceId: copyFromId, initial: null, failed: true })
+ return
+ }
+ }
+
+ if (cancelled) return
+ const source = data ? { ...data, items } : null
+ if (error || !source || !canCopyInvoice(source)) {
+ setCopyLoad({ sourceId: copyFromId, initial: null, failed: true })
+ return
+ }
+ setCopyLoad({
+ sourceId: copyFromId,
+ initial: buildInvoiceCopyInitial(source as InvoiceCopySource),
+ failed: false,
+ })
+ }
+
+ void loadCopySource()
+
+ return () => {
+ cancelled = true
+ }
+ }, [company?.id, copyFromId, open, supabase])
+
+ const copyInitial = copyLoad.sourceId === copyFromId ? copyLoad.initial : null
+ const copyLoadFailed = copyLoad.sourceId === copyFromId && copyLoad.failed
return (
@@ -50,8 +124,30 @@ export default function NewInvoiceDialog({ open, onOpenChange }: Props) {
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
- {t('title_invoice')}
-
+
+ {copyFromId ? t('title_copy') : t('title_invoice')}
+
+ {copyFromId ? (
+ copyLoadFailed ? (
+
+
{t('copy_load_failed_title')}
+
{t('copy_load_failed_description')}
+
onOpenChange(false)}>
+ {t('close')}
+
+
+ ) : copyInitial ? (
+
+ ) : (
+
+
+
+
+
+ )
+ ) : (
+
+ )}
)
diff --git a/components/invoices/SendInvoiceDialog.tsx b/components/invoices/SendInvoiceDialog.tsx
index 25b03044..51b53644 100644
--- a/components/invoices/SendInvoiceDialog.tsx
+++ b/components/invoices/SendInvoiceDialog.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
@@ -16,8 +16,10 @@ import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntry
import { proposeSendLines } from '@/lib/bookkeeping/propose-send-lines'
import { formatCurrency } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
+import { getResponseErrorMessage } from '@/lib/errors/get-error-message'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
+import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { Loader2, Mail, Send } from 'lucide-react'
import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types'
@@ -47,12 +49,16 @@ export default function SendInvoiceDialog({
const { company, isSandbox } = useCompany()
const canEmail = useCapability(CAPABILITY.email_send)
const t = useTranslations('invoice_send_dialog')
+ const locale = useLocale() as 'sv' | 'en'
+ const isCreditNote = !!invoice.credited_invoice_id
+ const isCreditRepair = isCreditNote && invoice.status === 'sent'
const [isSubmitting, setIsSubmitting] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const [entityType, setEntityType] = useState('enskild_firma')
const [periodName, setPeriodName] = useState('')
const [isInitialized, setIsInitialized] = useState(false)
+ const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true)
useEffect(() => {
if (!open) {
@@ -66,30 +72,44 @@ export default function SendInvoiceDialog({
try {
if (!company?.id) throw new Error(t('no_active_company'))
- // Fetch company settings
- const { data: settings, error } = await supabase
- .from('company_settings')
- .select('accounting_method, entity_type')
- .eq('company_id', company.id)
- .maybeSingle()
+ const [settingsResult, periodResult, originalResult] = await Promise.all([
+ supabase
+ .from('company_settings')
+ .select('accounting_method, entity_type')
+ .eq('company_id', company.id)
+ .maybeSingle(),
+ supabase
+ .from('fiscal_periods')
+ .select('name')
+ .eq('company_id', company.id)
+ .lte('start_date', invoice.invoice_date)
+ .gte('end_date', invoice.invoice_date)
+ .maybeSingle(),
+ invoice.credited_invoice_id
+ ? supabase
+ .from('invoices')
+ .select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
+ .eq('id', invoice.credited_invoice_id)
+ .eq('company_id', company.id)
+ .maybeSingle()
+ : Promise.resolve({ data: null, error: null }),
+ ])
- if (error) throw new Error(t('company_settings_failed'))
- if (cancelled) return
-
- // Fetch fiscal period for the invoice date
- const { data: period } = await supabase
- .from('fiscal_periods')
- .select('name')
- .eq('company_id', company.id)
- .lte('start_date', invoice.invoice_date)
- .gte('end_date', invoice.invoice_date)
- .maybeSingle()
+ if (settingsResult.error) throw new Error(t('company_settings_failed'))
+ if (periodResult.error) throw new Error(t('fiscal_period_failed'))
+ if (originalResult.error) throw new Error(t('original_invoice_failed'))
if (cancelled) return
- setAccountingMethod((settings?.accounting_method || 'accrual') as 'accrual' | 'cash')
- setEntityType((settings?.entity_type as EntityType) || 'enskild_firma')
- setPeriodName(period?.name || '')
+ const method = (settingsResult.data?.accounting_method || 'accrual') as 'accrual' | 'cash'
+ setAccountingMethod(method)
+ setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma')
+ setPeriodName(periodResult.data?.name || '')
+ setShouldBookOnIssue(
+ invoice.credited_invoice_id && originalResult.data
+ ? creditNoteNeedsJournalEntry(method, originalResult.data)
+ : method === 'accrual',
+ )
setIsInitialized(true)
} catch (err) {
if (cancelled) return
@@ -107,7 +127,7 @@ export default function SendInvoiceDialog({
}, [open, invoice.id, invoice.invoice_date, company?.id])
const proposedLines = useMemo(() => {
- if (!isInitialized || accountingMethod !== 'accrual') return []
+ if (!isInitialized || !shouldBookOnIssue) return []
return proposeSendLines({
invoice: {
@@ -121,12 +141,13 @@ export default function SendInvoiceDialog({
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
vat_treatment: invoice.vat_treatment,
+ credited_invoice_id: invoice.credited_invoice_id,
items: invoice.items,
default_dimensions: invoice.default_dimensions,
},
entityType,
})
- }, [isInitialized, accountingMethod, entityType, invoice])
+ }, [isInitialized, shouldBookOnIssue, entityType, invoice])
const { totalDebit, totalCredit } = useMemo(() => {
let totalDebit = 0
@@ -147,33 +168,62 @@ export default function SendInvoiceDialog({
: `/api/invoices/${invoice.id}/mark-sent`
const response = await fetch(url, { method: 'POST' })
- const data = await response.json()
if (!response.ok) {
- throw new Error(data.error || t('send_failed_fallback'))
+ throw new Error(await getResponseErrorMessage(response, 'invoice', locale))
}
+ const data = await response.json()
onSuccess()
if (mode === 'email') {
onOpenChange(false)
+ const successMessage = data.message || t('send_success_default', { email: invoice.customer.email ?? '' })
toast({
- title: t('send_success_title'),
- description: data.message || t('send_success_default', { email: invoice.customer.email ?? '' }),
+ title: t(
+ shouldBookOnIssue && !data.partial
+ ? isCreditNote
+ ? 'credit_send_book_success_title'
+ : 'send_book_success_title'
+ : isCreditNote
+ ? 'credit_send_success_title'
+ : 'send_success_title',
+ ),
+ description: data.partial
+ ? t('partial_success', { message: successMessage })
+ : isCreditNote
+ ? t('credit_send_success', { email: invoice.customer.email ?? '' })
+ : successMessage,
})
} else {
// For manual send, just close: no email to confirm
onOpenChange(false)
toast({
- title: t('mark_success_title'),
- description: accountingMethod === 'accrual'
- ? t('mark_success_voucher_created')
- : undefined,
+ title: t(
+ isCreditRepair
+ ? 'credit_repair_success_title'
+ : shouldBookOnIssue && !data.partial
+ ? isCreditNote
+ ? 'credit_mark_book_success_title'
+ : 'mark_book_success_title'
+ : isCreditNote
+ ? 'credit_mark_success_title'
+ : 'mark_success_title',
+ ),
+ description: data.partial
+ ? t('mark_partial_success')
+ : isCreditNote
+ ? shouldBookOnIssue
+ ? t('credit_mark_success_voucher_created')
+ : t('credit_mark_success_no_voucher')
+ : accountingMethod === 'accrual'
+ ? t('mark_success_voucher_created')
+ : undefined,
})
}
} catch (error) {
toast({
- title: t('send_failed_title'),
+ title: t(isCreditNote ? 'credit_send_failed_title' : 'send_failed_title'),
description: error instanceof Error ? error.message : t('try_again'),
variant: 'destructive',
})
@@ -186,14 +236,25 @@ export default function SendInvoiceDialog({
onOpenChange(false)
}
- const showJournalPreview = accountingMethod === 'accrual' && proposedLines.length > 0
+ const showJournalPreview = shouldBookOnIssue && proposedLines.length > 0
return (
- {mode === 'email' ? t('title_email') : t('title_manual')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
+ {t(
+ isCreditRepair
+ ? 'title_credit_repair'
+ : isCreditNote
+ ? mode === 'email'
+ ? 'title_credit_email'
+ : 'title_credit_manual'
+ : mode === 'email'
+ ? 'title_email'
+ : 'title_manual',
+ )}
+ {invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
{formatCurrency(invoice.total, invoice.currency)}
@@ -236,7 +297,7 @@ export default function SendInvoiceDialog({
) : (
- {accountingMethod === 'cash'
- ? t('explain_cash')
+ {!shouldBookOnIssue
+ ? t(isCreditNote ? 'explain_credit_cash' : 'explain_cash')
: mode === 'email'
? t('explain_email', { email: invoice.customer.email ?? '' })
: t('explain_manual')}
@@ -266,7 +327,7 @@ export default function SendInvoiceDialog({
disabled={isSubmitting}
className="w-full sm:w-auto min-h-11"
>
- {t('cancel')}
+ {t(isCreditNote ? 'later' : 'cancel')}
)}
- {mode === 'email' ? t('send_invoice') : t('mark_as_sent')}
+ {t(
+ isCreditRepair
+ ? 'complete_credit_bookkeeping'
+ : isCreditNote
+ ? mode === 'email'
+ ? shouldBookOnIssue
+ ? 'send_credit_note_and_book'
+ : 'send_credit_note'
+ : shouldBookOnIssue
+ ? 'mark_credit_note_sent_and_book'
+ : 'mark_credit_note_sent'
+ : mode === 'email'
+ ? shouldBookOnIssue
+ ? 'send_invoice_and_book'
+ : 'send_invoice'
+ : shouldBookOnIssue
+ ? 'mark_as_sent_and_book'
+ : 'mark_as_sent',
+ )}
diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx
index e1fd26c8..4e6e61d5 100644
--- a/components/settings/ApiKeysPanel.tsx
+++ b/components/settings/ApiKeysPanel.tsx
@@ -106,7 +106,7 @@ const SCOPE_GROUPS: ScopeGroup[] = [
{
domain: 'companies',
labelKey: 'group_companies',
- read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 0 },
+ read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 1 },
write: null,
},
{
diff --git a/components/settings/InvoiceSettingsForm.tsx b/components/settings/InvoiceSettingsForm.tsx
index 01b1bc1f..920ab892 100644
--- a/components/settings/InvoiceSettingsForm.tsx
+++ b/components/settings/InvoiceSettingsForm.tsx
@@ -92,6 +92,46 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
{t('default_our_reference_help')}
+
+
+ {t('reminder_days_heading')}
+ {t('reminder_days_help')}
+
+
)
}
diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx
index 71bc777a..58213173 100644
--- a/components/settings/sections/InvoicingSettingsContent.tsx
+++ b/components/settings/sections/InvoicingSettingsContent.tsx
@@ -51,6 +51,12 @@ export function InvoicingSettingsContent() {
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
default_our_reference: (formData.get('default_our_reference') as string) || null,
+ reminder_days_level_1:
+ Number.parseInt(formData.get('reminder_days_level_1') as string) || 15,
+ reminder_days_level_2:
+ Number.parseInt(formData.get('reminder_days_level_2') as string) || 30,
+ reminder_days_level_3:
+ Number.parseInt(formData.get('reminder_days_level_3') as string) || 45,
}
return {
updates,
diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx
index 70da9aff..7815d2a2 100644
--- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx
+++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx
@@ -28,9 +28,10 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
+import DocumentUploadZone, { type UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
-import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock, Tags } from 'lucide-react'
+import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock, Tags, Paperclip } from 'lucide-react'
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types'
interface LineItem {
@@ -66,6 +67,7 @@ interface ExistingSupplierInvoice {
// paths still return a flat string, so accept both.
interface CreateResult {
data?: { id: string; arrival_number: number }
+ warnings?: Array<{ code: string; message: string }>
error?:
| string
| {
@@ -303,6 +305,7 @@ export default function NewSupplierInvoiceForm({
// with onCreated (dialog mode), otherwise navigate like the old standalone
// page did.
const finishCreate = (invoiceId?: string) => {
+ createFinishedRef.current = true
if (onCreated) onCreated(invoiceId)
else router.push(afterCreate(invoiceId))
}
@@ -331,6 +334,9 @@ export default function NewSupplierInvoiceForm({
const [pendingSupplierSelect, setPendingSupplierSelect] = useState(null)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [newSupplier, setNewSupplier] = useState(EMPTY_NEW_SUPPLIER)
+ const [documentFiles, setDocumentFiles] = useState([])
+ const documentFilesRef = useRef([])
+ const createFinishedRef = useRef(false)
// Inbox/AI state
const [extractedData, setExtractedData] = useState(null)
@@ -379,6 +385,19 @@ export default function NewSupplierInvoiceForm({
useUnsavedChanges(isDirty)
+ useEffect(() => {
+ documentFilesRef.current = documentFiles
+ }, [documentFiles])
+
+ useEffect(() => () => {
+ if (inboxItemId || createFinishedRef.current) return
+ for (const file of documentFilesRef.current) {
+ if (file.status === 'uploaded' && file.id) {
+ void fetch(`/api/documents/${file.id}`, { method: 'DELETE', keepalive: true })
+ }
+ }
+ }, [inboxItemId])
+
const { fields, append, remove, replace } = useFieldArray({ control, name: 'items' })
const watchedItems = watch('items')
const watchedSupplierId = watch('supplier_id')
@@ -392,6 +411,9 @@ export default function NewSupplierInvoiceForm({
const watchedInvoiceDate = watch('invoice_date')
const watchedDueDate = watch('due_date')
const watchedPaymentReference = watch('payment_reference')
+ const documentUploadInProgress = documentFiles.some((file) => file.status === 'uploading')
+ const documentUploadFailed = documentFiles.some((file) => file.status === 'error')
+ const uploadedDocumentId = documentFiles.find((file) => file.status === 'uploaded')?.id
// Returns true when the field currently matches whatever the AI wrote
// when the form first loaded. Edits diverge it, hiding the dot.
function stillFromAi(value: string | null | undefined, original: string | null | undefined): boolean {
@@ -943,6 +965,7 @@ export default function NewSupplierInvoiceForm({
: data.due_date
return {
supplier_id: data.supplier_id,
+ ...(!inboxItemId && uploadedDocumentId ? { document_id: uploadedDocumentId } : {}),
supplier_invoice_number: data.supplier_invoice_number,
invoice_date: data.invoice_date,
due_date: dueDate,
@@ -1044,10 +1067,36 @@ export default function NewSupplierInvoiceForm({
body: JSON.stringify(buildPayload(data)),
})
const result = await res.json()
+ if (
+ res.ok &&
+ (result as CreateResult).warnings?.some((warning) => warning.code === 'DOCUMENT_LINK_FAILED')
+ ) {
+ toast({
+ title: t('document_link_warning_title'),
+ description: t('document_link_warning_description'),
+ variant: 'destructive',
+ })
+ }
return { ok: res.ok, status: res.status, result }
}
function onSubmit(data: FormData) {
+ if (documentUploadInProgress) {
+ toast({
+ title: t('document_upload_in_progress_title'),
+ description: t('document_upload_in_progress_description'),
+ variant: 'destructive',
+ })
+ return
+ }
+ if (documentUploadFailed) {
+ toast({
+ title: t('document_upload_failed_title'),
+ description: t('document_upload_failed_description'),
+ variant: 'destructive',
+ })
+ return
+ }
// Hard block: under faktureringsmetoden (and for privately-paid kvitton) a
// verifikation is posted at registration, and BFL 5 kap kräver att
// verifikationsnumret ligger i en obruten serie inom ett räkenskapsår. No
@@ -1328,6 +1377,34 @@ export default function NewSupplierInvoiceForm({
setTimeout(() => invoiceNumberInputRef.current?.focus(), 0)
}
+ function handleDocumentFilesChange(nextFiles: UploadedFile[]) {
+ const retainedKeys = new Set(nextFiles.map((file) => file.uploadKey))
+ const removedDocuments = documentFiles.filter(
+ (file) => file.id && !retainedKeys.has(file.uploadKey),
+ )
+
+ setDocumentFiles(nextFiles)
+ for (const document of removedDocuments) {
+ void fetch(`/api/documents/${document.id}`, { method: 'DELETE' })
+ }
+ }
+
+ async function discardUploadedDocument() {
+ const ids = documentFiles
+ .filter((file) => file.status === 'uploaded' && file.id)
+ .map((file) => file.id as string)
+ await Promise.allSettled(
+ ids.map((documentId) => fetch(`/api/documents/${documentId}`, { method: 'DELETE' })),
+ )
+ }
+
+ function handleCancel() {
+ void discardUploadedDocument().finally(() => {
+ if (onCancel) onCancel()
+ else router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices')
+ })
+ }
+
// Match-on-create: register the invoice, then match the picked transaction.
// EF goes straight through (auto-approve included). AB stores the picked
// transaction and routes through the same review dialog as the plain
@@ -1559,6 +1636,25 @@ export default function NewSupplierInvoiceForm({
)}
+ {!inboxItemId && (
+
+
+
+
+
{t('document_label')}
+
{t('document_help')}
+
+
+
+
+ )}
+
{/* Invoice-level default dims (kostnadsställe/projekt): applied to
every generated journal line; per-row bags in Kontering merge on
top. Renders only when dimensions are enabled for the company. */}
@@ -2052,10 +2148,8 @@ export default function NewSupplierInvoiceForm({
type="button"
variant="outline"
className="w-full sm:w-auto"
- onClick={() => {
- if (onCancel) onCancel()
- else router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices')
- }}
+ onClick={handleCancel}
+ disabled={isSubmitting || documentUploadInProgress}
>
{t('cancel')}
@@ -2064,7 +2158,7 @@ export default function NewSupplierInvoiceForm({
type="submit"
variant="outline"
className="w-full sm:w-auto"
- disabled={isSubmitting || !canWrite || showNoPeriodWarning}
+ disabled={isSubmitting || documentUploadInProgress || !canWrite || showNoPeriodWarning}
onClick={() => { submitModeRef.current = 'register_and_match' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
@@ -2074,7 +2168,7 @@ export default function NewSupplierInvoiceForm({
)}
{ submitModeRef.current = 'register' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
diff --git a/components/transactions/InvoicePicker.tsx b/components/transactions/InvoicePicker.tsx
index 79e48330..cf530578 100644
--- a/components/transactions/InvoicePicker.tsx
+++ b/components/transactions/InvoicePicker.tsx
@@ -48,6 +48,7 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
.select('*, customer:customers(*)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
+ .is('credited_invoice_id', null)
.in('status', ['sent', 'overdue', 'partially_paid'])
.gt('remaining_amount', 0)
.order('invoice_date', { ascending: false })
diff --git a/components/transactions/MatchAllocationDialog.tsx b/components/transactions/MatchAllocationDialog.tsx
index 576bd417..cc694c69 100644
--- a/components/transactions/MatchAllocationDialog.tsx
+++ b/components/transactions/MatchAllocationDialog.tsx
@@ -112,6 +112,7 @@ export default function MatchAllocationDialog({
.select('*, customer:customers(id, name)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
+ .is('credited_invoice_id', null)
.in('status', ['sent', 'overdue', 'partially_paid'])
.gt('remaining_amount', 0)
.order('due_date', { ascending: true })
diff --git a/extensions/general/email/manifest.json b/extensions/general/email/manifest.json
index dcdfea66..62b3957a 100644
--- a/extensions/general/email/manifest.json
+++ b/extensions/general/email/manifest.json
@@ -14,6 +14,6 @@
"dataPattern": "core",
"readsCoreTables": ["invoices", "customers", "company_settings"],
"description": "Skicka fakturor och påminnelser via e-post",
- "longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
+ "longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
}
}
diff --git a/extensions/general/mcp-server/__tests__/capability-gate.test.ts b/extensions/general/mcp-server/__tests__/capability-gate.test.ts
index f53a1bfa..917a3a1a 100644
--- a/extensions/general/mcp-server/__tests__/capability-gate.test.ts
+++ b/extensions/general/mcp-server/__tests__/capability-gate.test.ts
@@ -34,6 +34,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
},
},
)
+ const membershipChain: unknown = new Proxy(
+ {},
+ {
+ get(_t, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) =>
+ resolve({
+ data: {
+ company_id: '11111111-1111-4111-8111-111111111111',
+ role: 'owner',
+ },
+ error: null,
+ })
+ }
+ return () => membershipChain
+ },
+ }
+ )
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
@@ -48,7 +66,10 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
apiKeyId: 'key-1',
apiKeyName: 'Test Key',
}),
- createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
+ createServiceClientNoCookies: vi.fn(() => ({
+ from: (table: string) => (table === 'company_members' ? membershipChain : chain),
+ rpc: () => chain,
+ })),
}
})
diff --git a/extensions/general/mcp-server/__tests__/company-routing.test.ts b/extensions/general/mcp-server/__tests__/company-routing.test.ts
new file mode 100644
index 00000000..6daeb369
--- /dev/null
+++ b/extensions/general/mcp-server/__tests__/company-routing.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ addCompanyToNextHint,
+ addCompanyToTopLevelNext,
+ assertMcpCompanyWriteAccess,
+ extractRequestedCompany,
+ isCompanyDependentTool,
+ projectToolInputSchema,
+ resolveMcpCompanyContext,
+} from '../company-routing'
+
+const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
+const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
+
+function membershipClient(result: { data: unknown; error: unknown }) {
+ const chain: Record> = {
+ select: vi.fn(() => chain),
+ eq: vi.fn(() => chain),
+ is: vi.fn(() => chain),
+ maybeSingle: vi.fn().mockResolvedValue(result),
+ }
+ return {
+ client: { from: vi.fn(() => chain) },
+ chain,
+ }
+}
+
+describe('MCP company routing', () => {
+ it('projects company_id onto company-dependent tool schemas without mutating the source', () => {
+ const inputSchema = {
+ type: 'object',
+ additionalProperties: false,
+ properties: { invoice_id: { type: 'string' } },
+ required: ['invoice_id'],
+ }
+
+ const projected = projectToolInputSchema({ name: 'gnubok_send_invoice', inputSchema })
+
+ expect(projected).not.toBe(inputSchema)
+ expect(projected.properties).toEqual({
+ invoice_id: { type: 'string' },
+ company_id: expect.objectContaining({ type: 'string', format: 'uuid' }),
+ })
+ expect(inputSchema.properties).not.toHaveProperty('company_id')
+ expect(projected.additionalProperties).toBe(false)
+ })
+
+ it.each(['gnubok_search_tools', 'gnubok_load_skill', 'gnubok_list_companies'])(
+ 'keeps the company-independent schema unchanged for %s',
+ (name) => {
+ const inputSchema = {
+ type: 'object',
+ additionalProperties: false,
+ properties: {},
+ }
+
+ expect(isCompanyDependentTool(name)).toBe(false)
+ expect(projectToolInputSchema({ name, inputSchema })).toBe(inputSchema)
+ }
+ )
+
+ it('extracts and strips a valid company_id before tool execution', () => {
+ expect(
+ extractRequestedCompany({ company_id: OTHER_COMPANY_ID, invoice_id: 'invoice-1' })
+ ).toEqual({
+ requestedCompanyId: OTHER_COMPANY_ID,
+ toolArgs: { invoice_id: 'invoice-1' },
+ })
+ })
+
+ it('rejects a malformed company_id', () => {
+ expect(() => extractRequestedCompany({ company_id: 'not-a-uuid' })).toThrow(
+ expect.objectContaining({ code: 'VALIDATION_ERROR' })
+ )
+ })
+
+ it('checks membership and resolves the requested company role', async () => {
+ const { client, chain } = membershipClient({
+ data: { company_id: OTHER_COMPANY_ID, role: 'admin' },
+ error: null,
+ })
+
+ await expect(
+ resolveMcpCompanyContext({
+ supabase: client as never,
+ userId: 'user-1',
+ defaultCompanyId: DEFAULT_COMPANY_ID,
+ requestedCompanyId: OTHER_COMPANY_ID,
+ })
+ ).resolves.toEqual({
+ companyId: OTHER_COMPANY_ID,
+ role: 'admin',
+ isDefault: false,
+ })
+ expect(chain.eq).toHaveBeenCalledWith('user_id', 'user-1')
+ expect(chain.eq).toHaveBeenCalledWith('company_id', OTHER_COMPANY_ID)
+ expect(chain.is).toHaveBeenCalledWith('companies.archived_at', null)
+ })
+
+ it('checks the API key default company when company_id is omitted', async () => {
+ const { client, chain } = membershipClient({
+ data: { company_id: DEFAULT_COMPANY_ID, role: 'owner' },
+ error: null,
+ })
+
+ await expect(
+ resolveMcpCompanyContext({
+ supabase: client as never,
+ userId: 'user-1',
+ defaultCompanyId: DEFAULT_COMPANY_ID,
+ })
+ ).resolves.toEqual({
+ companyId: DEFAULT_COMPANY_ID,
+ role: 'owner',
+ isDefault: true,
+ })
+ expect(chain.eq).toHaveBeenCalledWith('company_id', DEFAULT_COMPANY_ID)
+ })
+
+ it('rejects companies without a current non-archived membership', async () => {
+ const { client } = membershipClient({ data: null, error: null })
+
+ await expect(
+ resolveMcpCompanyContext({
+ supabase: client as never,
+ userId: 'user-1',
+ defaultCompanyId: DEFAULT_COMPANY_ID,
+ requestedCompanyId: OTHER_COMPANY_ID,
+ })
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' })
+ })
+
+ it('fails closed when the membership lookup fails', async () => {
+ const { client } = membershipClient({
+ data: null,
+ error: { message: 'database unavailable' },
+ })
+
+ await expect(
+ resolveMcpCompanyContext({
+ supabase: client as never,
+ userId: 'user-1',
+ defaultCompanyId: DEFAULT_COMPANY_ID,
+ })
+ ).rejects.toMatchObject({ code: 'INTERNAL_ERROR' })
+ })
+
+ it('allows viewer reads but rejects viewer writes, approvals, and management', () => {
+ const context = { companyId: OTHER_COMPANY_ID, role: 'viewer' as const, isDefault: false }
+
+ expect(() => assertMcpCompanyWriteAccess(context, 'reports:read')).not.toThrow()
+ expect(() => assertMcpCompanyWriteAccess(context, undefined)).not.toThrow()
+ expect(() => assertMcpCompanyWriteAccess(context, 'invoices:write')).toThrow(
+ expect.objectContaining({ code: 'FORBIDDEN' })
+ )
+ expect(() => assertMcpCompanyWriteAccess(context, 'pending_operations:approve')).toThrow(
+ expect.objectContaining({ code: 'FORBIDDEN' })
+ )
+ expect(() => assertMcpCompanyWriteAccess(context, 'webhooks:manage')).toThrow(
+ expect.objectContaining({ code: 'FORBIDDEN' })
+ )
+ })
+
+ it('keeps company context in follow-up tool hints', () => {
+ const next = {
+ tool: 'gnubok_approve_pending_operation',
+ description: 'Approve the operation',
+ args: { operation_id: 'operation-1' },
+ }
+
+ expect(addCompanyToNextHint(next, OTHER_COMPANY_ID)).toEqual({
+ ...next,
+ args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
+ })
+ expect(addCompanyToTopLevelNext({ data: {}, next }, OTHER_COMPANY_ID)).toEqual({
+ data: {},
+ next: {
+ ...next,
+ args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
+ },
+ })
+ })
+})
diff --git a/extensions/general/mcp-server/__tests__/list-companies.test.ts b/extensions/general/mcp-server/__tests__/list-companies.test.ts
new file mode 100644
index 00000000..9d3c67f5
--- /dev/null
+++ b/extensions/general/mcp-server/__tests__/list-companies.test.ts
@@ -0,0 +1,121 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
+
+vi.mock('@/lib/company/context', () => ({
+ getUserCompanies: vi.fn(),
+}))
+
+import { getUserCompanies } from '@/lib/company/context'
+import { tools } from '../server'
+
+const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
+const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
+const ARCHIVED_COMPANY_ID = '33333333-3333-4333-8333-333333333333'
+const listCompaniesTool = tools.find((tool) => tool.name === 'gnubok_list_companies')!
+
+describe('gnubok_list_companies', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('is a read-only companies:read discovery tool', () => {
+ expect(listCompaniesTool).toBeDefined()
+ expect(TOOL_SCOPE_MAP.gnubok_list_companies).toBe('companies:read')
+ expect(listCompaniesTool.annotations).toMatchObject({
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ })
+ })
+
+ it('lists every non-archived membership with role and marks the default', async () => {
+ vi.mocked(getUserCompanies).mockResolvedValue([
+ {
+ company_id: DEFAULT_COMPANY_ID,
+ role: 'owner',
+ joined_at: '2026-01-01',
+ companies: {
+ id: DEFAULT_COMPANY_ID,
+ name: 'Legal Default AB',
+ org_number: '559000-0001',
+ entity_type: 'AB',
+ archived_at: null,
+ created_at: '2026-01-01',
+ },
+ },
+ {
+ company_id: OTHER_COMPANY_ID,
+ role: 'viewer',
+ joined_at: '2026-02-01',
+ companies: {
+ id: OTHER_COMPANY_ID,
+ name: 'Other Legal Name',
+ org_number: null,
+ entity_type: 'EF',
+ archived_at: null,
+ created_at: '2026-02-01',
+ },
+ },
+ {
+ company_id: ARCHIVED_COMPANY_ID,
+ role: 'admin',
+ joined_at: '2026-03-01',
+ companies: {
+ id: ARCHIVED_COMPANY_ID,
+ name: 'Archived AB',
+ org_number: '559000-0003',
+ entity_type: 'AB',
+ archived_at: '2026-06-01',
+ created_at: '2026-03-01',
+ },
+ },
+ ] as never)
+
+ const rangeMock = vi.fn().mockResolvedValue({
+ data: [{ company_id: DEFAULT_COMPANY_ID, company_name: 'Configured Default AB' }],
+ error: null,
+ })
+ const orderMock = vi.fn(() => ({ range: rangeMock }))
+ const inMock = vi.fn(() => ({ order: orderMock }))
+ const supabase = {
+ from: vi.fn(() => ({
+ select: vi.fn(() => ({ in: inMock })),
+ })),
+ }
+
+ const result = (await listCompaniesTool.execute(
+ {},
+ DEFAULT_COMPANY_ID,
+ 'user-1',
+ supabase as never,
+ { type: 'api_key' }
+ )) as Record
+
+ expect(getUserCompanies).toHaveBeenCalledWith(supabase, 'user-1')
+ expect(inMock).toHaveBeenCalledWith('company_id', [DEFAULT_COMPANY_ID, OTHER_COMPANY_ID])
+ expect(orderMock).toHaveBeenCalledWith('company_id', { ascending: true })
+ expect(rangeMock).toHaveBeenCalledWith(0, 999)
+ expect(result).toEqual({
+ companies: [
+ {
+ company_id: DEFAULT_COMPANY_ID,
+ name: 'Configured Default AB',
+ org_number: '559000-0001',
+ entity_type: 'AB',
+ role: 'owner',
+ is_default: true,
+ },
+ {
+ company_id: OTHER_COMPANY_ID,
+ name: 'Other Legal Name',
+ org_number: null,
+ entity_type: 'EF',
+ role: 'viewer',
+ is_default: false,
+ },
+ ],
+ count: 2,
+ default_company_id: DEFAULT_COMPANY_ID,
+ })
+ })
+})
diff --git a/extensions/general/mcp-server/__tests__/multi-company-dispatch.test.ts b/extensions/general/mcp-server/__tests__/multi-company-dispatch.test.ts
new file mode 100644
index 00000000..e9170d89
--- /dev/null
+++ b/extensions/general/mcp-server/__tests__/multi-company-dispatch.test.ts
@@ -0,0 +1,165 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { eventBus } from '@/lib/events/bus'
+
+const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
+const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
+
+const mocks = vi.hoisted(() => ({
+ membership: {
+ data: {
+ company_id: '22222222-2222-4222-8222-222222222222',
+ role: 'owner',
+ } as Record | null,
+ error: null as { message: string } | null,
+ },
+ companyIds: [] as string[],
+ hasCapability: vi.fn(),
+}))
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(),
+ createServiceClient: vi.fn(),
+}))
+
+vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ extractBearerToken: vi.fn().mockReturnValue('test-token'),
+ validateApiKey: vi.fn().mockResolvedValue({
+ userId: 'user-1',
+ companyId: '11111111-1111-4111-8111-111111111111',
+ scopes: ['companies:read', 'invoices:write', 'reports:read'],
+ apiKeyId: 'key-1',
+ apiKeyName: 'Test Key',
+ }),
+ createServiceClientNoCookies: vi.fn(() => ({
+ from: vi.fn((table: string) => {
+ if (table !== 'company_members') throw new Error(`Unexpected table: ${table}`)
+ const chain: Record> = {
+ select: vi.fn(() => chain),
+ eq: vi.fn((column: string, value: string) => {
+ if (column === 'company_id') mocks.companyIds.push(value)
+ return chain
+ }),
+ is: vi.fn(() => chain),
+ maybeSingle: vi.fn(async () => mocks.membership),
+ }
+ return chain
+ }),
+ })),
+ }
+})
+
+vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, hasCapability: mocks.hasCapability }
+})
+
+import { handleMcpRequest } from '../server'
+
+function toolCall(args: Record): Request {
+ return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/call',
+ params: { name: 'gnubok_send_invoice', arguments: args },
+ }),
+ })
+}
+
+async function parseToolResult(response: Response) {
+ const json = await response.json()
+ const result = json.result as { isError?: boolean; content: Array<{ text: string }> }
+ return {
+ isError: result.isError === true,
+ payload: JSON.parse(result.content[0].text) as Record,
+ }
+}
+
+describe('MCP multi-company dispatch', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ eventBus.clear()
+ mocks.companyIds.length = 0
+ mocks.membership = {
+ data: { company_id: OTHER_COMPANY_ID, role: 'owner' },
+ error: null,
+ }
+ mocks.hasCapability.mockResolvedValue(false)
+ })
+
+ it('routes a tool call to an accessible requested company', async () => {
+ const result = await parseToolResult(
+ await handleMcpRequest(
+ toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
+ )
+ )
+
+ expect(result.isError).toBe(true)
+ expect((result.payload.error as Record).capability_blocked).toBe(true)
+ expect(mocks.companyIds).toEqual([OTHER_COMPANY_ID])
+ expect(mocks.hasCapability).toHaveBeenCalledWith(
+ expect.anything(),
+ OTHER_COMPANY_ID,
+ 'email_send'
+ )
+ })
+
+ it('revalidates and uses the API key default company when company_id is omitted', async () => {
+ mocks.membership.data = { company_id: DEFAULT_COMPANY_ID, role: 'admin' }
+
+ await handleMcpRequest(toolCall({ invoice_id: 'invoice-1' }))
+
+ expect(mocks.companyIds).toEqual([DEFAULT_COMPANY_ID])
+ expect(mocks.hasCapability).toHaveBeenCalledWith(
+ expect.anything(),
+ DEFAULT_COMPANY_ID,
+ 'email_send'
+ )
+ })
+
+ it('rejects a company the user does not belong to before capability or execution', async () => {
+ mocks.membership.data = null
+
+ const result = await parseToolResult(
+ await handleMcpRequest(
+ toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
+ )
+ )
+
+ expect(result.isError).toBe(true)
+ expect((result.payload.error as Record).code).toBe('NOT_FOUND')
+ expect(mocks.hasCapability).not.toHaveBeenCalled()
+ })
+
+ it('rejects writes for a viewer in the selected company', async () => {
+ mocks.membership.data = { company_id: OTHER_COMPANY_ID, role: 'viewer' }
+
+ const result = await parseToolResult(
+ await handleMcpRequest(
+ toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
+ )
+ )
+
+ expect(result.isError).toBe(true)
+ expect((result.payload.error as Record).code).toBe('FORBIDDEN')
+ expect(mocks.hasCapability).not.toHaveBeenCalled()
+ })
+
+ it('rejects malformed company_id before querying membership', async () => {
+ const result = await parseToolResult(
+ await handleMcpRequest(
+ toolCall({ invoice_id: 'invoice-1', company_id: 'not-a-uuid' })
+ )
+ )
+
+ expect(result.isError).toBe(true)
+ expect((result.payload.error as Record).code).toBe('VALIDATION_ERROR')
+ expect(mocks.companyIds).toEqual([])
+ expect(mocks.hasCapability).not.toHaveBeenCalled()
+ })
+})
diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
index 882405f2..5f18833e 100644
--- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
+++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { tools, deriveToolMeta } from '../server'
+import { projectToolInputSchema } from '../company-routing'
describe('tools/list payload size guard', () => {
it('keeps the projected tools/list payload under the context-budget ceiling', () => {
@@ -12,7 +13,7 @@ describe('tools/list payload size guard', () => {
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description,
- inputSchema: t.inputSchema,
+ inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
@@ -20,7 +21,7 @@ describe('tools/list payload size guard', () => {
})
const payload = JSON.stringify({ tools: projection })
const approxTokens = Math.round(payload.length / 4)
- // Ceiling progression: 20K → 25K → 30K → 31K → 31.5K → 32K → 36K.
+ // Ceiling progression: 20K to 25K to 30K to 31K to 31.5K to 32K to 36K.
// * 20K → 25K when item 8 of the agent-native API plan landed
// (additionalProperties: false on all inputSchemas + period_status in the
// staged operation envelope).
@@ -110,9 +111,14 @@ describe('tools/list payload size guard', () => {
// gnubok_get_vacation_balance (ledger read) + gnubok_close_vacation_year
// (staged HIGH semesterårsavslut with STAGED_OPERATION_SCHEMA + _meta).
// Fortnox gap category E closed; both schemas already minimal.
+ // * 51K to 54K for stateless multi-company MCP routing. Every
+ // company-dependent tool must expose the optional company_id input so
+ // the client can target another authorized company without shared
+ // mutable connection state. The repeated property is intentionally
+ // minimal; gnubok_list_companies and initialize instructions explain it.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
- expect(approxTokens).toBeLessThan(51_000)
+ expect(approxTokens).toBeLessThan(54_000)
})
})
diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts
index aa135992..fd7da8f3 100644
--- a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts
+++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts
@@ -24,6 +24,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
+ companyId: '11111111-1111-4111-8111-111111111111',
scopes: ['transactions:read', 'transactions:write', 'customers:read', 'customers:write', 'invoices:read', 'invoices:write', 'suppliers:read', 'reports:read'],
}),
createServiceClientNoCookies: vi.fn(),
@@ -179,7 +180,30 @@ describe('MCP Receipt Matcher', () => {
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
- vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
+ const membershipChain: unknown = new Proxy(
+ {},
+ {
+ get(_t, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) =>
+ resolve({
+ data: {
+ company_id: '11111111-1111-4111-8111-111111111111',
+ role: 'owner',
+ },
+ error: null,
+ })
+ }
+ return () => membershipChain
+ },
+ }
+ )
+ vi.mocked(createServiceClientNoCookies).mockReturnValue({
+ ...supabase,
+ from: vi.fn((table: string) =>
+ table === 'company_members' ? membershipChain : supabase.from(table)
+ ),
+ } as never)
})
// ── Protocol: initialize includes resources capability ──
diff --git a/extensions/general/mcp-server/__tests__/search-tools.test.ts b/extensions/general/mcp-server/__tests__/search-tools.test.ts
index d15732c1..0151f354 100644
--- a/extensions/general/mcp-server/__tests__/search-tools.test.ts
+++ b/extensions/general/mcp-server/__tests__/search-tools.test.ts
@@ -55,6 +55,19 @@ describe('gnubok_search_tools', () => {
const tool = result.tools[0]
expect(tool).toHaveProperty('inputSchema')
expect(tool).toHaveProperty('outputSchema')
+ expect(
+ (tool.inputSchema as { properties: Record }).properties
+ ).toHaveProperty('company_id')
+ })
+
+ it('does not add company_id to company-independent discovery tools', async () => {
+ const result = await call({ detail: 'full', query: 'search tools', limit: 5 })
+ const tool = result.tools.find((candidate) => candidate.name === 'gnubok_search_tools')
+
+ expect(tool).toBeDefined()
+ expect(
+ (tool!.inputSchema as { properties: Record }).properties
+ ).not.toHaveProperty('company_id')
})
it('filters by query keyword', async () => {
diff --git a/extensions/general/mcp-server/__tests__/strict-schemas.test.ts b/extensions/general/mcp-server/__tests__/strict-schemas.test.ts
index ba0c421b..bc11d930 100644
--- a/extensions/general/mcp-server/__tests__/strict-schemas.test.ts
+++ b/extensions/general/mcp-server/__tests__/strict-schemas.test.ts
@@ -11,6 +11,8 @@
*/
import { describe, it, expect } from 'vitest'
import { tools } from '../server'
+import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
+import { isTenantWriteScope } from '../company-routing'
describe('MCP tool inputSchema strictness', () => {
it('every tool inputSchema has additionalProperties: false at the top level', () => {
@@ -22,4 +24,21 @@ describe('MCP tool inputSchema strictness', () => {
.map((t) => t.name)
expect(missing).toEqual([])
})
+
+ it('every tenant write tool has a scope that the central role guard can classify', () => {
+ const allowedNonTenantWrites = new Set([
+ 'gnubok_audit_package',
+ 'gnubok_feedback',
+ ])
+ const missing = tools
+ .filter(
+ (tool) =>
+ tool.annotations.readOnlyHint !== true &&
+ !isTenantWriteScope(TOOL_SCOPE_MAP[tool.name]) &&
+ !allowedNonTenantWrites.has(tool.name)
+ )
+ .map((tool) => tool.name)
+
+ expect(missing).toEqual([])
+ })
})
diff --git a/extensions/general/mcp-server/__tests__/telemetry.test.ts b/extensions/general/mcp-server/__tests__/telemetry.test.ts
index f50ffb98..ef6a2ab8 100644
--- a/extensions/general/mcp-server/__tests__/telemetry.test.ts
+++ b/extensions/general/mcp-server/__tests__/telemetry.test.ts
@@ -22,7 +22,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
- companyId: 'company-1',
+ companyId: '11111111-1111-4111-8111-111111111111',
// Only reports:read: enough to call gnubok_get_trial_balance, NOT enough
// to call gnubok_create_invoice (invoices:write). Drives the scope-denied test.
scopes: ['reports:read'],
@@ -35,6 +35,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
// filter has data to work against.
createServiceClientNoCookies: vi.fn(() => ({
from: vi.fn((table: string) => {
+ if (table === 'company_members') {
+ return {
+ select: vi.fn(() => {
+ const chain: Record> = {
+ eq: vi.fn(() => chain),
+ is: vi.fn(() => chain),
+ maybeSingle: vi.fn().mockResolvedValue({
+ data: {
+ company_id: '11111111-1111-4111-8111-111111111111',
+ role: 'owner',
+ },
+ error: null,
+ }),
+ }
+ return chain
+ }),
+ }
+ }
if (table === 'company_settings') {
return {
select: vi.fn(() => ({
@@ -191,7 +209,7 @@ describe('mcp.tool_called telemetry', () => {
expect(event.actorId).toBe('key-1')
expect(event.actorLabel).toBe('Test Key')
expect(event.userId).toBe('user-1')
- expect(event.companyId).toBe('company-1')
+ expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
expect(event.requestId).toBe(1)
// Real wall-clock: non-negative number
expect(typeof event.latencyMs).toBe('number')
@@ -393,7 +411,7 @@ describe('mcp.tools_list_called telemetry', () => {
expect(event.toolCount).toBeLessThan(100)
expect(event.actorType).toBe('api_key')
expect(event.userId).toBe('user-1')
- expect(event.companyId).toBe('company-1')
+ expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
expect(typeof event.latencyMs).toBe('number')
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
})
@@ -496,7 +514,7 @@ describe('mcp.skill_loaded telemetry', () => {
expect(event.actorType).toBe('api_key')
expect(event.actorId).toBe('key-1')
expect(event.userId).toBe('user-1')
- expect(event.companyId).toBe('company-1')
+ expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
// The pre-existing workflow-funnel event still fires for workflow tier.
const wf = await workflowStartedPromise
diff --git a/extensions/general/mcp-server/__tests__/test-key-write-block.test.ts b/extensions/general/mcp-server/__tests__/test-key-write-block.test.ts
index dd4927df..d99910e1 100644
--- a/extensions/general/mcp-server/__tests__/test-key-write-block.test.ts
+++ b/extensions/general/mcp-server/__tests__/test-key-write-block.test.ts
@@ -29,6 +29,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
},
},
)
+ const membershipChain: unknown = new Proxy(
+ {},
+ {
+ get(_t, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) =>
+ resolve({
+ data: {
+ company_id: '11111111-1111-4111-8111-111111111111',
+ role: 'owner',
+ },
+ error: null,
+ })
+ }
+ return () => membershipChain
+ },
+ }
+ )
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
@@ -42,7 +60,10 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
apiKeyName: 'Test Key',
mode: 'test',
}),
- createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
+ createServiceClientNoCookies: vi.fn(() => ({
+ from: (table: string) => (table === 'company_members' ? membershipChain : chain),
+ rpc: () => chain,
+ })),
}
})
diff --git a/extensions/general/mcp-server/__tests__/vat-review-widget.test.ts b/extensions/general/mcp-server/__tests__/vat-review-widget.test.ts
index 9614d0df..bcb030ff 100644
--- a/extensions/general/mcp-server/__tests__/vat-review-widget.test.ts
+++ b/extensions/general/mcp-server/__tests__/vat-review-widget.test.ts
@@ -19,7 +19,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
- companyId: 'company-1',
+ companyId: '11111111-1111-4111-8111-111111111111',
scopes: ['reports:read'],
}),
// Fully-chainable, awaitable proxy resolving to empty data: satisfies both
@@ -38,7 +38,27 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
},
},
)
- return { from: () => makeChain() }
+ const membershipChain: unknown = new Proxy(
+ {},
+ {
+ get(_t, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) =>
+ resolve({
+ data: {
+ company_id: '11111111-1111-4111-8111-111111111111',
+ role: 'owner',
+ },
+ error: null,
+ })
+ }
+ return () => membershipChain
+ },
+ }
+ )
+ return {
+ from: (table: string) => (table === 'company_members' ? membershipChain : makeChain()),
+ }
}),
}
})
diff --git a/extensions/general/mcp-server/company-routing.ts b/extensions/general/mcp-server/company-routing.ts
new file mode 100644
index 00000000..30c0352c
--- /dev/null
+++ b/extensions/general/mcp-server/company-routing.ts
@@ -0,0 +1,146 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import type { ApiKeyScope } from '@/lib/auth/api-keys'
+import type { CompanyRole } from '@/types'
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+
+const COMPANY_INDEPENDENT_TOOLS = new Set([
+ 'gnubok_search_tools',
+ 'gnubok_load_skill',
+ 'gnubok_list_companies',
+])
+
+export const COMPANY_ID_INPUT_PROPERTY = {
+ type: 'string',
+ format: 'uuid',
+ description: 'Target company ID. Omit for default.',
+} as const
+
+export interface McpCompanyContext {
+ companyId: string
+ role: CompanyRole
+ isDefault: boolean
+}
+
+interface ToolSchemaSource {
+ name: string
+ inputSchema: Record
+}
+
+function codedError(
+ code: 'VALIDATION_ERROR' | 'NOT_FOUND' | 'FORBIDDEN' | 'INTERNAL_ERROR',
+ message: string
+) {
+ return Object.assign(new Error(message), { code })
+}
+
+function isCompanyRole(value: unknown): value is CompanyRole {
+ return value === 'owner' || value === 'admin' || value === 'member' || value === 'viewer'
+}
+
+export function isCompanyDependentTool(toolName: string): boolean {
+ return !COMPANY_INDEPENDENT_TOOLS.has(toolName)
+}
+
+export function isTenantWriteScope(scope: ApiKeyScope | undefined): boolean {
+ return (
+ scope?.endsWith(':write') === true ||
+ scope?.endsWith(':approve') === true ||
+ scope?.endsWith(':manage') === true
+ )
+}
+
+export function projectToolInputSchema(tool: ToolSchemaSource): Record {
+ if (!isCompanyDependentTool(tool.name)) return tool.inputSchema
+
+ const properties =
+ tool.inputSchema.properties && typeof tool.inputSchema.properties === 'object'
+ ? (tool.inputSchema.properties as Record)
+ : {}
+
+ return {
+ ...tool.inputSchema,
+ properties: {
+ ...properties,
+ company_id: COMPANY_ID_INPUT_PROPERTY,
+ },
+ }
+}
+
+export function extractRequestedCompany(
+ rawArgs: Record
+): { requestedCompanyId: string | undefined; toolArgs: Record } {
+ const { company_id: rawCompanyId, ...toolArgs } = rawArgs
+ if (rawCompanyId === undefined) return { requestedCompanyId: undefined, toolArgs }
+ if (typeof rawCompanyId !== 'string' || !UUID_PATTERN.test(rawCompanyId)) {
+ throw codedError('VALIDATION_ERROR', 'company_id must be a valid UUID')
+ }
+ return { requestedCompanyId: rawCompanyId, toolArgs }
+}
+
+export async function resolveMcpCompanyContext(args: {
+ supabase: SupabaseClient
+ userId: string
+ defaultCompanyId: string
+ requestedCompanyId?: string
+}): Promise {
+ const companyId = args.requestedCompanyId ?? args.defaultCompanyId
+
+ const { data: membership, error } = await args.supabase
+ .from('company_members')
+ .select('company_id, role, companies!inner(archived_at)')
+ .eq('user_id', args.userId)
+ .eq('company_id', companyId)
+ .is('companies.archived_at', null)
+ .maybeSingle()
+
+ if (error) {
+ throw codedError('INTERNAL_ERROR', `Failed to resolve company membership: ${error.message}`)
+ }
+ if (!membership) {
+ throw codedError('NOT_FOUND', 'Company not found')
+ }
+ if (!isCompanyRole(membership.role)) {
+ throw codedError('FORBIDDEN', 'Company membership has an unsupported role')
+ }
+
+ return {
+ companyId,
+ role: membership.role,
+ isDefault: companyId === args.defaultCompanyId,
+ }
+}
+
+export function assertMcpCompanyWriteAccess(
+ context: McpCompanyContext,
+ scope: ApiKeyScope | undefined
+): void {
+ if (context.role === 'viewer' && isTenantWriteScope(scope)) {
+ throw codedError('FORBIDDEN', 'Write permission required for this company')
+ }
+}
+
+export function addCompanyToNextHint(next: unknown, companyId: string): unknown {
+ if (!next || typeof next !== 'object' || Array.isArray(next)) return next
+ const hint = next as Record
+ if (typeof hint.tool !== 'string' || !isCompanyDependentTool(hint.tool)) return next
+ const args =
+ hint.args && typeof hint.args === 'object' && !Array.isArray(hint.args)
+ ? (hint.args as Record)
+ : {}
+ return {
+ ...hint,
+ args: { ...args, company_id: companyId },
+ }
+}
+
+export function addCompanyToTopLevelNext(result: unknown, companyId: string): unknown {
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return result
+ const record = result as Record
+ if (!record.next) return result
+ return {
+ ...record,
+ next: addCompanyToNextHint(record.next, companyId),
+ }
+}
diff --git a/extensions/general/mcp-server/resources/company-current.ts b/extensions/general/mcp-server/resources/company-current.ts
index 87f3556a..31e81dec 100644
--- a/extensions/general/mcp-server/resources/company-current.ts
+++ b/extensions/general/mcp-server/resources/company-current.ts
@@ -12,7 +12,7 @@ import type { McpResource } from './types'
export const companyCurrentResource: McpResource = {
uri: 'Accounted://company/current',
name: 'Active Company',
- description: 'Per-company working memory: identity, active fiscal period, lock dates, entity counts, voucher series state, recent activity, approaching Swedish filing deadlines. Read this first when starting work on a company.',
+ description: 'Working memory for the API key default company: identity, active fiscal period, lock dates, entity counts, voucher series state, recent activity, and filing deadlines. For another company, call gnubok_get_agent_briefing with company_id.',
mimeType: 'application/json',
read: async ({ supabase, companyId }) => {
const today = new Date().toISOString().slice(0, 10)
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts
index c2dd9f09..48510d01 100644
--- a/extensions/general/mcp-server/server.ts
+++ b/extensions/general/mcp-server/server.ts
@@ -58,6 +58,15 @@ import {
IdempotencyKeyReuseError,
} from '@/lib/api/idempotency'
import { toToolError, type NextActionHint } from './tool-result'
+import {
+ addCompanyToNextHint,
+ addCompanyToTopLevelNext,
+ assertMcpCompanyWriteAccess,
+ extractRequestedCompany,
+ isCompanyDependentTool,
+ projectToolInputSchema,
+ resolveMcpCompanyContext,
+} from './company-routing'
import { findSupplierCandidates } from './supplier-candidates'
import { assertNoPlaintextPersonnummer } from './staging-pii-guard'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
@@ -112,6 +121,7 @@ import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { commitPendingOperation } from '@/lib/pending-operations/commit'
import { appendProcessingHistory } from '@/lib/processing-history/append'
+import { getUserCompanies } from '@/lib/company/context'
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
// which dispatches to this handler: no duplicate call needed here.
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType } from '@/types'
@@ -333,7 +343,7 @@ async function stagePendingOperation(
message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`,
preview: previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
- ...(next ? { next } : {}),
+ ...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
}
}
@@ -358,7 +368,12 @@ async function stagePendingOperation(
? `Replayed cached response for idempotency_key "${options.idempotencyKey}": already staged as pending_operation ${cachedOpId}. No new side-effects. ${buildApprovalGuidance(cachedOpId, riskLevel)}`
: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`,
...(cachedOpId
- ? { approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: cachedOpId } } }
+ ? {
+ approve: {
+ tool: 'gnubok_approve_pending_operation',
+ args: { operation_id: cachedOpId, company_id: companyId },
+ },
+ }
: {}),
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
@@ -399,11 +414,11 @@ async function stagePendingOperation(
message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.`,
approve: {
tool: 'gnubok_approve_pending_operation',
- args: { operation_id: data.id } as Record,
+ args: { operation_id: data.id, company_id: companyId } as Record,
},
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
- ...(next ? { next } : {}),
+ ...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
} as const
if (options.idempotencyKey && requestHash) {
@@ -1760,7 +1775,7 @@ export const tools: McpTool[] = [
name: t.name,
description: t.description,
scope: requiredScope,
- inputSchema: t.inputSchema,
+ inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
@@ -1779,6 +1794,109 @@ export const tools: McpTool[] = [
},
},
+ {
+ name: 'gnubok_list_companies',
+ title: 'List Companies',
+ description: 'List every non-archived company this API-key user can access. Use company_id from this result on other tools; omit it there to use the API key default.',
+ inputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {},
+ },
+ outputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ companies: {
+ type: 'array',
+ items: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ company_id: { type: 'string' },
+ name: { type: 'string' },
+ org_number: { type: ['string', 'null'] },
+ entity_type: { type: ['string', 'null'] },
+ role: { type: 'string', enum: ['owner', 'admin', 'member', 'viewer'] },
+ is_default: { type: 'boolean' },
+ },
+ required: ['company_id', 'name', 'org_number', 'entity_type', 'role', 'is_default'],
+ },
+ },
+ count: { type: 'number' },
+ default_company_id: { type: ['string', 'null'] },
+ },
+ required: ['companies', 'count', 'default_company_id'],
+ },
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ async execute(_args, defaultCompanyId, userId, supabase) {
+ type CompanyRow = {
+ id: string
+ name: string
+ org_number: string | null
+ entity_type: string | null
+ archived_at: string | null
+ }
+ type MembershipRow = {
+ company_id: string
+ role: 'owner' | 'admin' | 'member' | 'viewer'
+ companies: CompanyRow | CompanyRow[] | null
+ }
+
+ const memberships = (await getUserCompanies(supabase, userId)) as unknown as MembershipRow[]
+ const accessible = memberships.flatMap((membership) => {
+ const company = Array.isArray(membership.companies)
+ ? membership.companies[0]
+ : membership.companies
+ return company && company.archived_at === null ? [{ membership, company }] : []
+ })
+ const companyIds = accessible.map(({ company }) => company.id)
+ const displayNames = new Map()
+
+ if (companyIds.length > 0) {
+ try {
+ const settings = await fetchAllRows<{ company_id: string; company_name: string | null }>(
+ ({ from, to }) =>
+ supabase
+ .from('company_settings')
+ .select('company_id, company_name')
+ .in('company_id', companyIds)
+ .order('company_id', { ascending: true })
+ .range(from, to),
+ )
+ for (const row of settings) {
+ if (row.company_name) displayNames.set(row.company_id, row.company_name)
+ }
+ } catch (error) {
+ log.warn('gnubok_list_companies display-name lookup failed', {
+ error: error instanceof Error ? error.message : 'unknown',
+ })
+ }
+ }
+
+ const companies = accessible.map(({ membership, company }) => ({
+ company_id: company.id,
+ name: displayNames.get(company.id) ?? company.name,
+ org_number: company.org_number,
+ entity_type: company.entity_type,
+ role: membership.role,
+ is_default: company.id === defaultCompanyId,
+ }))
+ const hasAccessibleDefault = companies.some((company) => company.is_default)
+
+ return {
+ companies,
+ count: companies.length,
+ default_company_id: hasAccessibleDefault ? defaultCompanyId : null,
+ }
+ },
+ },
+
{
name: 'gnubok_list_skills',
title: 'List Domain Skills',
@@ -2245,10 +2363,10 @@ export const tools: McpTool[] = [
type: 'object',
additionalProperties: false,
description:
- 'The single company every tool call in this session reads and writes. Confirm this is the entity the user means BEFORE any staged write: there is no per-call company switch; scope is fixed by the API key.',
+ 'The company selected for this call. Confirm it is the entity the user means before staging a write. Pass company_id on later calls to keep working in a non-default company.',
properties: {
id: { type: 'string', description: 'Deprecated: read company_id instead.' },
- company_id: { type: 'string', description: 'company_id this session is scoped to.' },
+ company_id: { type: 'string', description: 'company_id selected for this call.' },
name: { type: ['string', 'null'] },
org_number: { type: ['string', 'null'] },
entity_type: { type: ['string', 'null'], description: 'e.g. "aktiebolag", "enskild_firma". Null if unset.' },
@@ -2493,10 +2611,10 @@ export const tools: McpTool[] = [
.select('full_name')
.eq('id', userId)
.maybeSingle(),
- // Company identity so the agent can confirm WHICH entity it operates on
- // before any write. Scope is fixed by the API key: there is no per-call
- // switch: so this is the session's "whoami for the company". Best-effort:
- // a failed read still yields a company block with at least the id.
+ // Company identity so the agent can confirm which entity it operates on
+ // before any write. The dispatcher has already resolved and authorized
+ // the optional per-call company_id. A failed read still yields a company
+ // block with at least the id.
supabase
.from('companies')
.select('name, org_number, entity_type')
@@ -12006,20 +12124,6 @@ export const tools: McpTool[] = [
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const assetIds = Array.isArray(args.asset_ids) ? (args.asset_ids as string[]) : undefined
- // Mirror the HTTP route's `requireWrite: true` guard so a viewer-role
- // member can't post depreciation through the MCP surface. RLS would
- // reject the underlying INSERTs anyway, but failing fast here
- // produces a much cleaner error than the cascaded RLS rejection.
- const { data: membership } = await supabase
- .from('company_members')
- .select('role')
- .eq('company_id', companyId)
- .eq('user_id', userId)
- .maybeSingle()
- if (!membership || membership.role === 'viewer') {
- throw new Error('Write permission required')
- }
-
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
@@ -12629,7 +12733,7 @@ function emitToolCallTelemetry(payload: {
success: boolean
isError: boolean
errorCode: string | null
- errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
+ errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
errorMessage: string | null
requestId: string | number | null
userId: string
@@ -12957,6 +13061,8 @@ export async function handleMcpRequest(request: Request): Promise {
'',
'Discovery:',
'• tools/list returns the full schema for every tool. To narrow a large catalog, call gnubok_search_tools(query="…"): it ranks tools by relevance; pass detail="name"|"summary"|"full" to control payload size.',
+ `• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`,
+ '• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
'',
'Common workflows:',
@@ -13014,7 +13120,7 @@ export async function handleMcpRequest(request: Request): Promise {
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description,
- inputSchema: t.inputSchema,
+ inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
@@ -13026,7 +13132,7 @@ export async function handleMcpRequest(request: Request): Promise {
case 'tools/call': {
const toolName = (params as Record)?.name as string
- const toolArgs = ((params as Record)?.arguments ?? {}) as Record<
+ const rawToolArgs = ((params as Record)?.arguments ?? {}) as Record<
string,
unknown
>
@@ -13085,12 +13191,55 @@ export async function handleMcpRequest(request: Request): Promise {
)
}
+ let toolArgs: Record
+ let effectiveCompanyId = companyId
+ const companyRoutingStartedAt = Date.now()
+ try {
+ const extracted = extractRequestedCompany(rawToolArgs)
+ toolArgs = extracted.toolArgs
+
+ if (isCompanyDependentTool(toolName)) {
+ const companyContext = await resolveMcpCompanyContext({
+ supabase,
+ userId,
+ defaultCompanyId: companyId,
+ requestedCompanyId: extracted.requestedCompanyId,
+ })
+ assertMcpCompanyWriteAccess(companyContext, requiredScope)
+ effectiveCompanyId = companyContext.companyId
+ }
+ } catch (err) {
+ const structured = toToolError(err, { toolName })
+ emitToolCallTelemetry({
+ tool: toolName,
+ requiredScope: requiredScope ?? null,
+ actor,
+ latencyMs: Date.now() - companyRoutingStartedAt,
+ success: false,
+ isError: true,
+ errorCode: structured.error.code,
+ errorKind: 'company_access_denied',
+ errorMessage: structured.error.message_sv,
+ requestId: id ?? null,
+ userId,
+ // Keep denied attempts attributed to the key default. An arbitrary,
+ // unauthorized target must never create tenant telemetry there.
+ companyId,
+ })
+ return NextResponse.json(
+ jsonRpc(id ?? null, {
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
+ isError: true,
+ })
+ )
+ }
+
// Enforce the capability paywall: the MCP/agent path is a paid chokepoint
// just like the HTTP routes (send_invoice → email_send, the two SKV
// submissions → skatteverket). Fail-closed; self-hosted short-circuits to
// all-on inside hasCapability. Blocks before any pending op is staged.
const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName]
- if (requiredCapability && !(await hasCapability(supabase, companyId, requiredCapability))) {
+ if (requiredCapability && !(await hasCapability(supabase, effectiveCompanyId, requiredCapability))) {
const capError = { error: capabilityBlockedError(requiredCapability) }
emitToolCallTelemetry({
tool: toolName,
@@ -13104,7 +13253,7 @@ export async function handleMcpRequest(request: Request): Promise {
errorMessage: capError.error.message_sv,
requestId: id ?? null,
userId,
- companyId,
+ companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
@@ -13144,7 +13293,7 @@ export async function handleMcpRequest(request: Request): Promise {
errorMessage: blocked.error.message_sv,
requestId: id ?? null,
userId,
- companyId,
+ companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
@@ -13158,7 +13307,7 @@ export async function handleMcpRequest(request: Request): Promise {
// Detect if THIS call follows the previous call's `next` hint: must
// run before execute() so we don't double-store on this call. Emits
// mcp.next_hint_followed when the agent's behaviour matches the hint.
- checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, companyId)
+ checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, effectiveCompanyId)
const callStartedAt = Date.now()
try {
@@ -13167,7 +13316,8 @@ export async function handleMcpRequest(request: Request): Promise {
if (toolName === 'gnubok_search_tools') {
(toolArgs as Record).__keyScopes = keyScopes
}
- const result = await tool.execute(toolArgs, companyId, userId, supabase, actor)
+ const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor)
+ const result = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
const latencyMs = Date.now() - callStartedAt
const response: Record = {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -13208,7 +13358,7 @@ export async function handleMcpRequest(request: Request): Promise {
errorMessage: null,
requestId: id ?? null,
userId,
- companyId,
+ companyId: effectiveCompanyId,
})
return NextResponse.json(jsonRpc(id ?? null, response))
} catch (err) {
@@ -13229,7 +13379,7 @@ export async function handleMcpRequest(request: Request): Promise {
errorMessage: structured.error.message_sv,
requestId: id ?? null,
userId,
- companyId,
+ companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts
index 320c484c..672b2c11 100644
--- a/lib/api/__tests__/schemas.test.ts
+++ b/lib/api/__tests__/schemas.test.ts
@@ -1410,6 +1410,24 @@ describe('UpdateSettingsSchema', () => {
})
})
+ describe('reminder day thresholds', () => {
+ it('accepts integer thresholds from 1 through 365', () => {
+ const result = UpdateSettingsSchema.safeParse({
+ reminder_days_level_1: 7,
+ reminder_days_level_2: 21,
+ reminder_days_level_3: 365,
+ })
+
+ expect(result.success).toBe(true)
+ })
+
+ it.each([0, 366, 1.5])('rejects invalid threshold %s', (days) => {
+ const result = UpdateSettingsSchema.safeParse({ reminder_days_level_1: days })
+
+ expect(result.success).toBe(false)
+ })
+ })
+
describe('invoice_email_texts', () => {
it('accepts a valid nested partial', () => {
const result = UpdateSettingsSchema.safeParse({
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 524154e0..b57c6fb9 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -681,9 +681,34 @@ export const CreateCustomerSchema = z.object({
language: z.enum(['sv', 'en']).optional(),
default_payment_terms: z.number().int().positive().optional(),
notes: z.string().optional(),
+}).superRefine((customer, ctx) => {
+ if (customer.personal_number && customer.customer_type !== 'individual') {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['personal_number'],
+ message: 'Personal number is only allowed for individual customers',
+ })
+ }
})
-export const UpdateCustomerSchema = CreateCustomerSchema.partial()
+export const UpdateCustomerSchema = z.object({
+ name: z.string().min(1, 'Customer name is required').optional(),
+ customer_type: CustomerTypeSchema.optional(),
+ customer_number: z.string().trim().max(32).nullable().optional(),
+ email: z.string().email('Invalid email address').optional(),
+ phone: z.string().optional(),
+ address_line1: z.string().optional(),
+ address_line2: z.string().optional(),
+ postal_code: z.string().optional(),
+ city: z.string().optional(),
+ country: z.string().optional(),
+ org_number: z.string().optional(),
+ vat_number: z.string().optional(),
+ personal_number: z.string().regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number').nullable().optional(),
+ language: z.enum(['sv', 'en']).optional(),
+ default_payment_terms: z.number().int().positive().optional(),
+ notes: z.string().optional(),
+})
// ============================================================
// Supplier schemas
@@ -786,6 +811,9 @@ export const CreateSupplierInvoiceItemSchema = z.object({
export const CreateSupplierInvoiceSchema = z.object({
supplier_id: uuid,
+ // Optional invoice PDF/image already stored in the WORM document archive.
+ // The route verifies company ownership and that the document is unused.
+ document_id: uuid.optional(),
supplier_invoice_number: z.string().min(1, 'Supplier invoice number is required'),
invoice_date: isoDate,
due_date: isoDate,
@@ -1493,6 +1521,9 @@ export const UpdateSettingsSchema = z.object({
invoice_footer_text: z.string().max(500).nullable().optional(),
// Automation
send_invoice_reminders: z.boolean().optional(),
+ reminder_days_level_1: z.number().int().min(1).max(365).optional(),
+ reminder_days_level_2: z.number().int().min(1).max(365).optional(),
+ reminder_days_level_3: z.number().int().min(1).max(365).optional(),
// Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
reminder_fee_enabled: z.boolean().optional(),
reminder_fee_amount: z
diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts
index 1c1baf19..d6f87a51 100644
--- a/lib/auth/api-keys.ts
+++ b/lib/auth/api-keys.ts
@@ -158,6 +158,8 @@ export const SCOPE_GROUPS = [
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
export const TOOL_SCOPE_MAP: Record = {
+ // Companies
+ gnubok_list_companies: 'companies:read',
// Transactions
gnubok_list_uncategorized_transactions: 'transactions:read',
gnubok_list_transactions_without_documents: 'transactions:read',
diff --git a/lib/bookkeeping/__tests__/invoice-entries.test.ts b/lib/bookkeeping/__tests__/invoice-entries.test.ts
index 71f274d8..97d7a980 100644
--- a/lib/bookkeeping/__tests__/invoice-entries.test.ts
+++ b/lib/bookkeeping/__tests__/invoice-entries.test.ts
@@ -453,6 +453,45 @@ describe('createCreditNoteJournalEntry: per-line VAT', () => {
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
})
+
+ it.each([
+ ['rot' as const, 3000, 9500],
+ ['rut' as const, 5000, 7500],
+ ])('reverses the %s receivable split across 1510 and 1513', async (deductionType, taxCredit, customerCredit) => {
+ const creditNote = makeInvoice({
+ invoice_number: 'KR-1002',
+ subtotal: -10000,
+ vat_amount: -2500,
+ total: -12500,
+ vat_treatment: 'standard_25',
+ items: [
+ makeItem({
+ quantity: -1,
+ unit_price: 10000,
+ line_total: -10000,
+ vat_rate: 25,
+ vat_amount: -2500,
+ deduction_type: deductionType,
+ dimensions: { '6': 'P001' },
+ }),
+ ],
+ default_dimensions: { '1': 'KS01' },
+ })
+
+ await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
+ const input = mockedCreateEntry.mock.calls[0][3]
+
+ expect(input.lines.find((line) => line.account_number === '1510')?.credit_amount)
+ .toBe(customerCredit)
+ const line1513 = input.lines.find((line) => line.account_number === '1513')
+ expect(line1513?.credit_amount).toBe(taxCredit)
+ expect(line1513?.debit_amount).toBe(0)
+ expect(line1513?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
+
+ const totalDebit = input.lines.reduce((sum, line) => sum + line.debit_amount, 0)
+ const totalCredit = input.lines.reduce((sum, line) => sum + line.credit_amount, 0)
+ expect(totalDebit).toBe(totalCredit)
+ })
})
describe('createInvoiceCashEntry: per-line VAT', () => {
diff --git a/lib/bookkeeping/__tests__/propose-send-lines.test.ts b/lib/bookkeeping/__tests__/propose-send-lines.test.ts
index f9cc2ad3..021baafe 100644
--- a/lib/bookkeeping/__tests__/propose-send-lines.test.ts
+++ b/lib/bookkeeping/__tests__/propose-send-lines.test.ts
@@ -30,6 +30,7 @@ function makeInvoiceInput(overrides: Partial<{
currency: string
exchange_rate: number | null
vat_treatment: VatTreatment
+ credited_invoice_id: string | null
items: InvoiceItem[]
default_dimensions: Record | null
}> = {}) {
@@ -77,6 +78,71 @@ describe('proposeSendLines', () => {
})
})
+ it('credit note uses positive amounts on the reversed sides', () => {
+ const lines = proposeSendLines({
+ invoice: makeInvoiceInput({
+ invoice_number: 'KR-2025-001',
+ credited_invoice_id: 'invoice-1',
+ total: -12500,
+ subtotal: -10000,
+ vat_amount: -2500,
+ items: [
+ makeItem({ quantity: -1, line_total: -10000, vat_amount: -2500 }),
+ ],
+ }),
+ entityType: 'enskild_firma',
+ })
+
+ expect(lines).toEqual([
+ {
+ account_number: '1510',
+ debit_amount: '',
+ credit_amount: '12500',
+ line_description: 'Kreditfaktura KR-2025-001',
+ },
+ {
+ account_number: '3001',
+ debit_amount: '10000',
+ credit_amount: '',
+ line_description: 'Kreditfaktura KR-2025-001',
+ },
+ {
+ account_number: '2611',
+ debit_amount: '2500',
+ credit_amount: '',
+ line_description: 'Moms kreditfaktura 25%',
+ },
+ ])
+ })
+
+ it('credit-note preview reverses the ROT receivable split', () => {
+ const lines = proposeSendLines({
+ invoice: makeInvoiceInput({
+ invoice_number: 'KR-2025-002',
+ credited_invoice_id: 'invoice-2',
+ total: -12500,
+ subtotal: -10000,
+ vat_amount: -2500,
+ items: [
+ makeItem({
+ quantity: -1,
+ line_total: -10000,
+ vat_amount: -2500,
+ deduction_type: 'rot',
+ }),
+ ],
+ }),
+ entityType: 'enskild_firma',
+ })
+
+ expect(lines.find((line) => line.account_number === '1510')?.credit_amount).toBe('9500')
+ expect(lines.find((line) => line.account_number === '1513')?.credit_amount).toBe('3000')
+ expect(lines.reduce((sum, line) => sum + (parseFloat(line.debit_amount) || 0), 0))
+ .toBe(12500)
+ expect(lines.reduce((sum, line) => sum + (parseFloat(line.credit_amount) || 0), 0))
+ .toBe(12500)
+ })
+
describe('dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' }
diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts
index 0e608a84..8314d109 100644
--- a/lib/bookkeeping/invoice-entries.ts
+++ b/lib/bookkeeping/invoice-entries.ts
@@ -11,6 +11,7 @@ import { generateSalesVatLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
import { createLogger } from '@/lib/logger'
+import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
@@ -258,6 +259,7 @@ function generateRotRutLines(
currency?: string | null,
exchangeRate?: number | null,
defaultDimensions?: LineDimensions,
+ side: 'debit' | 'credit' = 'debit',
): { lines: CreateJournalEntryLineInput[]; totalSek: number } {
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
@@ -276,8 +278,8 @@ function generateRotRutLines(
if (!item.deduction_type) continue
// Recompute server-side to defend against tampered client values.
const amount = computeDeduction({
- unit_price: item.unit_price,
- quantity: item.quantity,
+ unit_price: side === 'credit' ? Math.abs(item.unit_price) : item.unit_price,
+ quantity: side === 'credit' ? Math.abs(item.quantity) : item.quantity,
deduction_type: item.deduction_type,
})
if (amount <= 0) continue
@@ -287,9 +289,11 @@ function generateRotRutLines(
const kind = item.deduction_type === 'rot' ? 'ROT' : 'RUT'
lines.push({
account_number: '1513',
- debit_amount: amountSek,
- credit_amount: 0,
- line_description: `${kind}-avdrag faktura ${invoiceTagText}`,
+ debit_amount: side === 'debit' ? amountSek : 0,
+ credit_amount: side === 'credit' ? amountSek : 0,
+ line_description: side === 'credit'
+ ? `${kind}-avdrag kreditfaktura ${invoiceTagText}`
+ : `${kind}-avdrag faktura ${invoiceTagText}`,
// Per-item line: carries the item's merged bag like its revenue line.
dimensions: mergeDimensionBags(defaultDimensions, item.dimensions),
})
@@ -648,15 +652,30 @@ export async function createCreditNoteJournalEntry(
lines.push(...debitLines)
- // Credit: Kundfordringar, balance guarantee: credit = sum of all debit lines
+ // ROT/RUT reverses the exact receivable split used by the original invoice:
+ // 1510 for the customer portion and 1513 for the Skatteverket portion.
+ const rotRut = creditNote.items && creditNote.items.length > 0
+ ? generateRotRutLines(
+ creditNote.items,
+ tag,
+ creditNote.currency,
+ creditNote.exchange_rate,
+ defaultDimensions,
+ 'credit',
+ )
+ : { lines: [], totalSek: 0 }
+
+ // Credit: Kundfordringar, balance guarantee: 1510 + 1513 equals debits.
const totalDebits = debitLines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const customerReceivable = roundOre(totalDebits - rotRut.totalSek)
lines.push({
account_number: '1510',
debit_amount: 0,
- credit_amount: Math.round(totalDebits * 100) / 100,
+ credit_amount: customerReceivable,
line_description: `Kreditfaktura ${tag}`,
dimensions: defaultDimensions,
})
+ lines.push(...rotRut.lines)
const baseDescription = buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id)
const input: CreateJournalEntryInput = {
diff --git a/lib/bookkeeping/propose-send-lines.ts b/lib/bookkeeping/propose-send-lines.ts
index 2cf008e9..10e3f2a3 100644
--- a/lib/bookkeeping/propose-send-lines.ts
+++ b/lib/bookkeeping/propose-send-lines.ts
@@ -7,6 +7,8 @@
import { resolveSekAmount } from './currency-utils'
import { getRevenueAccount, getOutputVatAccount } from './invoice-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
+import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
+import { roundOre } from '@/lib/money'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
@@ -22,6 +24,7 @@ export interface ProposeSendLinesInput {
currency: string
exchange_rate?: number | null
vat_treatment: VatTreatment
+ credited_invoice_id?: string | null
items?: InvoiceItem[]
/**
* Dimensions PR7: the invoice's default bag, stamped on every proposed
@@ -47,13 +50,51 @@ function toFormAmount(n: number): string {
*/
export function proposeSendLines(input: ProposeSendLinesInput): FormLine[] {
const { invoice, entityType } = input
+ const proposedLines = invoice.credited_invoice_id
+ ? buildCreditNoteLines(invoice, entityType)
+ : buildSendLines(invoice, entityType)
const lines: FormLine[] = stampProposalDimensions(
- buildSendLines(invoice, entityType),
+ proposedLines,
invoice.default_dimensions
)
return lines
}
+function absoluteOptional(amount: number | null | undefined): number | null | undefined {
+ return amount == null ? amount : Math.abs(amount)
+}
+
+function buildCreditNoteLines(
+ invoice: ProposeSendLinesInput['invoice'],
+ entityType: EntityType,
+): FormLine[] {
+ const absoluteInvoice: ProposeSendLinesInput['invoice'] = {
+ ...invoice,
+ total: Math.abs(invoice.total),
+ total_sek: absoluteOptional(invoice.total_sek),
+ subtotal: Math.abs(invoice.subtotal),
+ subtotal_sek: absoluteOptional(invoice.subtotal_sek),
+ vat_amount: Math.abs(invoice.vat_amount),
+ vat_amount_sek: absoluteOptional(invoice.vat_amount_sek),
+ items: invoice.items?.map((item) => ({
+ ...item,
+ quantity: Math.abs(item.quantity),
+ line_total: Math.abs(item.line_total),
+ vat_amount: item.vat_amount == null ? item.vat_amount : Math.abs(item.vat_amount),
+ })),
+ }
+
+ return buildSendLines(absoluteInvoice, entityType).map((line) => ({
+ ...line,
+ debit_amount: line.credit_amount,
+ credit_amount: line.debit_amount,
+ line_description: line.line_description
+ .replace('Försäljning faktura', 'Kreditfaktura')
+ .replace('Utgående moms faktura', 'Moms kreditfaktura')
+ .replace('Utgående moms', 'Moms kreditfaktura'),
+ }))
+}
+
function stampProposalDimensions(
lines: FormLine[],
bag?: Record | null
@@ -164,19 +205,41 @@ function buildSendLines(
}
}
- // Debit: 1510 Kundfordringar, balance guarantee
+ const deductionLines: FormLine[] = []
+ let deductionTotal = 0
+ for (const item of invoice.items ?? []) {
+ if (!item.deduction_type) continue
+ const deduction = computeDeduction({
+ unit_price: item.unit_price,
+ quantity: item.quantity,
+ deduction_type: item.deduction_type,
+ })
+ const amountSek = roundOre(toSek(deduction))
+ if (amountSek <= 0) continue
+ deductionTotal = roundOre(deductionTotal + amountSek)
+ deductionLines.push({
+ account_number: '1513',
+ debit_amount: toFormAmount(amountSek),
+ credit_amount: '',
+ line_description: `${item.deduction_type === 'rot' ? 'ROT' : 'RUT'}-avdrag faktura ${invoice.invoice_number ?? ''}`.trim(),
+ })
+ }
+
+ // Debit: 1510 customer portion plus 1513 Skatteverket portion.
const totalCredits = creditLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const debitAmount = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
+ const customerReceivable = roundOre(debitAmount - deductionTotal)
lines.push({
account_number: '1510',
- debit_amount: toFormAmount(debitAmount),
+ debit_amount: toFormAmount(customerReceivable),
credit_amount: '',
line_description: desc,
})
+ lines.push(...deductionLines)
lines.push(...creditLines)
return lines
diff --git a/lib/company/context.ts b/lib/company/context.ts
index 762b2500..0adfd9e7 100644
--- a/lib/company/context.ts
+++ b/lib/company/context.ts
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
+import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { cookies } from 'next/headers'
import type { EntityType } from '@/types'
@@ -154,26 +155,27 @@ export async function getUserCompanies(
supabase: SupabaseClient,
userId: string
) {
- const { data, error } = await supabase
- .from('company_members')
- .select(`
- company_id,
- role,
- joined_at,
- companies:company_id (
+ return fetchAllRows(({ from, to }) =>
+ supabase
+ .from('company_members')
+ .select(`
id,
- name,
- org_number,
- entity_type,
- archived_at,
- created_at
- )
- `)
- .eq('user_id', userId)
- .order('joined_at', { ascending: true })
-
- if (error) throw error
- return data ?? []
+ company_id,
+ role,
+ joined_at,
+ companies:company_id (
+ id,
+ name,
+ org_number,
+ entity_type,
+ archived_at,
+ created_at
+ )
+ `)
+ .eq('user_id', userId)
+ .order('id', { ascending: true })
+ .range(from, to),
+ )
}
/**
diff --git a/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts b/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts
index eb636cfe..e88268c5 100644
--- a/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts
+++ b/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts
@@ -52,6 +52,23 @@ describe('getJournalEntryUnderlagReferences', () => {
expect(refs).toEqual([{ type: 'supplier_invoice', id: 'si-1', number: 'LF-001' }])
})
+ it('surfaces the retained PDF through a supplier payment reference', async () => {
+ const refs = await run([
+ { data: [] }, // 1. invoices direct
+ { data: [] }, // 2. invoice_payments
+ { data: [] }, // 4. supplier registration
+ { data: [{ id: 'si-1', supplier_invoice_number: 'LF-001', document_id: 'doc-1' }] }, // 5. supplier payment
+ { data: [{ supplier_invoice_id: 'si-1' }] }, // 6. supplier_invoice_payments
+ ])
+
+ expect(refs).toEqual([{
+ type: 'supplier_invoice',
+ id: 'si-1',
+ number: 'LF-001',
+ document_id: 'doc-1',
+ }])
+ })
+
it('returns nothing when no invoice is linked (warning legitimately stays)', async () => {
const refs = await run([
{ data: [] }, // 1. invoices direct
diff --git a/lib/core/bookkeeping/journal-entry-references.ts b/lib/core/bookkeeping/journal-entry-references.ts
index 792b305f..deabbcda 100644
--- a/lib/core/bookkeeping/journal-entry-references.ts
+++ b/lib/core/bookkeeping/journal-entry-references.ts
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
+import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* A followable reference from a verifikation back to its underlag: the customer
@@ -20,6 +21,8 @@ export interface UnderlagReference {
id: string
/** invoice_number / supplier_invoice_number: the UI builds the label from this. */
number: string
+ /** Retained source document owned by a referenced supplier invoice, if any. */
+ document_id?: string
}
interface InvoiceRow {
@@ -30,6 +33,7 @@ interface InvoiceRow {
interface SupplierInvoiceRow {
id: string
supplier_invoice_number: string
+ document_id?: string | null
}
/**
@@ -52,21 +56,21 @@ export async function getJournalEntryUnderlagReferences(
const invoices = new Map()
// Direct link (faktureringsmetod registration, or invoices.journal_entry_id).
- const { data: directInvoices } = await supabase
- .from('invoices')
- .select('id, invoice_number')
- .eq('company_id', companyId)
- .eq('journal_entry_id', journalEntryId)
+ const directInvoices = await fetchAllRows(({ from, to }) =>
+ supabase.from('invoices').select('id, invoice_number')
+ .eq('company_id', companyId).eq('journal_entry_id', journalEntryId)
+ .order('id', { ascending: true }).range(from, to),
+ )
for (const inv of (directInvoices ?? []) as InvoiceRow[]) {
invoices.set(inv.id, inv.invoice_number)
}
// Payment rows (kontantmetod inbetalning, partial payments) → invoice_payments.
- const { data: paymentRows } = await supabase
- .from('invoice_payments')
- .select('invoice_id')
- .eq('journal_entry_id', journalEntryId)
+ const paymentRows = await fetchAllRows<{ id: string; invoice_id: string | null }>(({ from, to }) =>
+ supabase.from('invoice_payments').select('id, invoice_id')
+ .eq('journal_entry_id', journalEntryId).order('id', { ascending: true }).range(from, to),
+ )
const paymentInvoiceIds = new Set()
for (const row of (paymentRows ?? []) as { invoice_id: string | null }[]) {
@@ -74,11 +78,11 @@ export async function getJournalEntryUnderlagReferences(
}
if (paymentInvoiceIds.size > 0) {
- const { data: paidInvoices } = await supabase
- .from('invoices')
- .select('id, invoice_number')
- .eq('company_id', companyId)
- .in('id', Array.from(paymentInvoiceIds))
+ const paidInvoices = await fetchAllRows(({ from, to }) =>
+ supabase.from('invoices').select('id, invoice_number')
+ .eq('company_id', companyId).in('id', Array.from(paymentInvoiceIds))
+ .order('id', { ascending: true }).range(from, to),
+ )
for (const inv of (paidInvoices ?? []) as InvoiceRow[]) {
invoices.set(inv.id, inv.invoice_number)
@@ -86,35 +90,41 @@ export async function getJournalEntryUnderlagReferences(
}
// --- Supplier invoices ---------------------------------------------------
- const supplierInvoices = new Map()
+ const supplierInvoices = new Map()
// Registration booking (accrual) on the invoice itself.
- const { data: registrationLinks } = await supabase
- .from('supplier_invoices')
- .select('id, supplier_invoice_number')
- .eq('company_id', companyId)
- .eq('registration_journal_entry_id', journalEntryId)
+ const registrationLinks = await fetchAllRows(({ from, to }) =>
+ supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
+ .eq('company_id', companyId).eq('registration_journal_entry_id', journalEntryId)
+ .order('id', { ascending: true }).range(from, to),
+ )
for (const si of (registrationLinks ?? []) as SupplierInvoiceRow[]) {
- supplierInvoices.set(si.id, si.supplier_invoice_number)
+ supplierInvoices.set(si.id, {
+ number: si.supplier_invoice_number,
+ ...(si.document_id ? { documentId: si.document_id } : {}),
+ })
}
// Payment booking on the invoice itself.
- const { data: paymentLinks } = await supabase
- .from('supplier_invoices')
- .select('id, supplier_invoice_number')
- .eq('company_id', companyId)
- .eq('payment_journal_entry_id', journalEntryId)
+ const paymentLinks = await fetchAllRows(({ from, to }) =>
+ supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
+ .eq('company_id', companyId).eq('payment_journal_entry_id', journalEntryId)
+ .order('id', { ascending: true }).range(from, to),
+ )
for (const si of (paymentLinks ?? []) as SupplierInvoiceRow[]) {
- supplierInvoices.set(si.id, si.supplier_invoice_number)
+ supplierInvoices.set(si.id, {
+ number: si.supplier_invoice_number,
+ ...(si.document_id ? { documentId: si.document_id } : {}),
+ })
}
// Partial-payment rows → supplier_invoice_payments.
- const { data: supplierPaymentRows } = await supabase
- .from('supplier_invoice_payments')
- .select('supplier_invoice_id')
- .eq('journal_entry_id', journalEntryId)
+ const supplierPaymentRows = await fetchAllRows<{ id: string; supplier_invoice_id: string | null }>(
+ ({ from, to }) => supabase.from('supplier_invoice_payments').select('id, supplier_invoice_id')
+ .eq('journal_entry_id', journalEntryId).order('id', { ascending: true }).range(from, to),
+ )
const supplierPaymentIds = new Set()
for (const row of (supplierPaymentRows ?? []) as { supplier_invoice_id: string | null }[]) {
@@ -124,20 +134,30 @@ export async function getJournalEntryUnderlagReferences(
}
if (supplierPaymentIds.size > 0) {
- const { data: paidSupplierInvoices } = await supabase
- .from('supplier_invoices')
- .select('id, supplier_invoice_number')
- .eq('company_id', companyId)
- .in('id', Array.from(supplierPaymentIds))
+ const paidSupplierInvoices = await fetchAllRows(({ from, to }) =>
+ supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
+ .eq('company_id', companyId).in('id', Array.from(supplierPaymentIds))
+ .order('id', { ascending: true }).range(from, to),
+ )
for (const si of (paidSupplierInvoices ?? []) as SupplierInvoiceRow[]) {
- supplierInvoices.set(si.id, si.supplier_invoice_number)
+ supplierInvoices.set(si.id, {
+ number: si.supplier_invoice_number,
+ ...(si.document_id ? { documentId: si.document_id } : {}),
+ })
}
}
// --- Assemble ------------------------------------------------------------
const references: UnderlagReference[] = []
for (const [id, number] of invoices) references.push({ type: 'invoice', id, number })
- for (const [id, number] of supplierInvoices) references.push({ type: 'supplier_invoice', id, number })
+ for (const [id, supplierInvoice] of supplierInvoices) {
+ references.push({
+ type: 'supplier_invoice',
+ id,
+ number: supplierInvoice.number,
+ ...(supplierInvoice.documentId ? { document_id: supplierInvoice.documentId } : {}),
+ })
+ }
return references
}
diff --git a/lib/customers/mask-personal-number.ts b/lib/customers/mask-personal-number.ts
new file mode 100644
index 00000000..a5686380
--- /dev/null
+++ b/lib/customers/mask-personal-number.ts
@@ -0,0 +1,8 @@
+/**
+ * Display a personal identity number without exposing birth date or full ID.
+ */
+export function maskCustomerPersonalNumber(value: string | null | undefined): string | null {
+ if (!value) return null
+ const last4 = value.replace(/\D/g, '').slice(-4)
+ return last4.length === 4 ? `********-${last4}` : null
+}
diff --git a/lib/customers/protect-personal-number.ts b/lib/customers/protect-personal-number.ts
new file mode 100644
index 00000000..47f4adc0
--- /dev/null
+++ b/lib/customers/protect-personal-number.ts
@@ -0,0 +1,21 @@
+import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
+import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number'
+
+export function encryptCustomerPersonalNumber(value: string | null | undefined): string | null {
+ return value ? encryptPersonnummer(value) : null
+}
+
+export function maskStoredCustomerPersonalNumber(value: string | null | undefined): string | null {
+ if (!value) return null
+ if (/^(\d{6}|\d{8})[-+]?\d{4}$/.test(value)) {
+ return maskCustomerPersonalNumber(value)
+ }
+ return maskCustomerPersonalNumber(decryptPersonnummer(value))
+}
+
+export function maskCustomerRow(row: T): T {
+ return {
+ ...row,
+ personal_number: maskStoredCustomerPersonalNumber(row.personal_number),
+ }
+}
diff --git a/lib/email/reminder-templates.ts b/lib/email/reminder-templates.ts
index 26886203..03f8d07c 100644
--- a/lib/email/reminder-templates.ts
+++ b/lib/email/reminder-templates.ts
@@ -355,10 +355,34 @@ export function generateReminderEmailSubject(data: ReminderEmailData): string {
/**
* Get the number of days after due date for each reminder level
*/
-export function getReminderDaysConfig(): Record<1 | 2 | 3, number> {
- return {
+export type ReminderDaysConfig = Record<1 | 2 | 3, number>
+
+type ReminderDaysSettings = Partial<
+ Pick<
+ CompanySettings,
+ 'reminder_days_level_1' | 'reminder_days_level_2' | 'reminder_days_level_3'
+ >
+>
+
+export function getReminderDaysConfig(
+ settings?: ReminderDaysSettings | null,
+): ReminderDaysConfig {
+ const defaults: ReminderDaysConfig = {
1: REMINDER_CONFIG[1].daysAfterDue,
2: REMINDER_CONFIG[2].daysAfterDue,
- 3: REMINDER_CONFIG[3].daysAfterDue
+ 3: REMINDER_CONFIG[3].daysAfterDue,
}
+
+ const configured: ReminderDaysConfig = {
+ 1: settings?.reminder_days_level_1 ?? defaults[1],
+ 2: settings?.reminder_days_level_2 ?? defaults[2],
+ 3: settings?.reminder_days_level_3 ?? defaults[3],
+ }
+
+ const valid =
+ Object.values(configured).every((days) => Number.isInteger(days) && days >= 1 && days <= 365)
+ && configured[1] < configured[2]
+ && configured[2] < configured[3]
+
+ return valid ? configured : defaults
}
diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts
index 3503b8c5..5c6df1f5 100644
--- a/lib/errors/structured-errors.ts
+++ b/lib/errors/structured-errors.ts
@@ -428,6 +428,11 @@ const MATCH_INVOICE: Record = {
message_sv: 'Fakturan är inte i ett obetalt läge och kan inte matchas.',
message_en: 'Invoice is not in an unpaid state.',
},
+ MATCH_INVOICE_CREDIT_NOTE: {
+ httpStatus: 400,
+ message_sv: 'Kreditfakturor kan inte registreras som betalda.',
+ message_en: 'Credit notes cannot be recorded as paid.',
+ },
MATCH_INVOICE_NOT_INVOICE_TYPE: {
httpStatus: 400,
message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.',
@@ -523,6 +528,11 @@ const LINK_TX_JE: Record = {
message_sv: 'Fakturan är inte i ett obetalt läge och kan inte kopplas.',
message_en: 'Invoice is not in an unpaid state.',
},
+ LINK_TX_INVOICE_CREDIT_NOTE: {
+ httpStatus: 400,
+ message_sv: 'Kreditfakturor kan inte registreras som betalda.',
+ message_en: 'Credit notes cannot be recorded as paid.',
+ },
LINK_TX_INVOICE_RACE: {
httpStatus: 409,
message_sv: 'Fakturan ändrades samtidigt. Försök igen.',
@@ -754,6 +764,48 @@ const INVOICE: Record = {
message_sv: 'Endast skickade, betalda eller förfallna fakturor kan krediteras.',
message_en: 'Only sent, paid, or overdue invoices can be credited.',
},
+ INVOICE_CREDIT_ISSUE_INCOMPLETE: {
+ httpStatus: 500,
+ message_sv:
+ 'Kreditfakturan kunde inte utfärdas färdigt. Ingen e-post skickades. Försök igen.',
+ message_en:
+ 'The credit note could not be issued completely. No email was sent. Please try again.',
+ },
+ INVOICE_CREDIT_REPAIR_REQUIRED: {
+ httpStatus: 500,
+ message_sv: 'Kreditfakturans verifikat skapades, men utfärdandet måste slutföras. Försök igen eller kontakta support.',
+ message_en: 'The credit-note voucher was created, but issuance must be completed. Retry or contact support.',
+ },
+ INVOICE_CREDIT_ALREADY_ISSUED: {
+ httpStatus: 409,
+ message_sv: 'Kreditfakturan har redan utfärdats.',
+ message_en: 'The credit note has already been issued.',
+ },
+ INVOICE_MARK_SENT_INVALID_STATUS: {
+ httpStatus: 400,
+ message_sv: 'Fakturan kan inte markeras som skickad i nuvarande status.',
+ message_en: 'The invoice cannot be marked as sent in its current status.',
+ },
+ INVOICE_MARK_SENT_STATUS_FAILED: {
+ httpStatus: 500,
+ message_sv: 'Fakturans status kunde inte uppdateras.',
+ message_en: 'The invoice status could not be updated.',
+ },
+ INVOICE_MARK_SENT_RACE: {
+ httpStatus: 409,
+ message_sv: 'Fakturan ändrades av en annan begäran. Ladda om och försök igen.',
+ message_en: 'The invoice was changed by another request. Reload and retry.',
+ },
+ INVOICE_MARK_SENT_BOOK_FAILED: {
+ httpStatus: 500,
+ message_sv: 'Fakturan kunde inte bokföras och ligger kvar som utkast.',
+ message_en: 'The invoice could not be posted and remains a draft.',
+ },
+ INVOICE_MARK_SENT_REPAIR_REQUIRED: {
+ httpStatus: 500,
+ message_sv: 'Verifikatet skapades, men kopplingen till fakturan måste återställas. Kontakta support.',
+ message_en: 'The voucher was created, but its invoice link must be repaired. Contact support.',
+ },
INVOICE_SEND_EMAIL_NOT_CONFIGURED: {
httpStatus: 503,
message_sv:
@@ -1639,6 +1691,26 @@ const ARTICLE: Record = {
message_sv: 'Artikeln kunde inte uppdateras.',
message_en: 'Failed to update article.',
},
+ INVOICE_DELETE_FAILED: {
+ httpStatus: 500,
+ message_sv: 'Fakturan kunde inte tas bort eller makuleras.',
+ message_en: 'The invoice could not be deleted or cancelled.',
+ },
+ CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED: {
+ httpStatus: 400,
+ message_sv: 'Personnummer kan endast sparas för privatkunder.',
+ message_en: 'Personal numbers can only be stored for individual customers.',
+ },
+ ARTICLE_DELETE_FAILED: {
+ httpStatus: 500,
+ message_sv: 'Artikeln kunde inte tas bort.',
+ message_en: 'Failed to delete article.',
+ },
+ ARTICLE_IN_USE: {
+ httpStatus: 409,
+ message_sv: 'Artikeln har använts på en faktura och kan därför inte tas bort.',
+ message_en: 'The article has been used on an invoice and cannot be deleted.',
+ },
ARTICLE_REVENUE_ACCOUNT_INVALID: {
httpStatus: 400,
message_sv: 'Försäljningskontot finns inte eller är inte ett aktivt intäktskonto (klass 3).',
diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts
index 1292f040..079af910 100644
--- a/lib/events/handlers/event-log-handler.ts
+++ b/lib/events/handlers/event-log-handler.ts
@@ -26,6 +26,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
'period.locked',
'period.year_closed',
'customer.created',
+ 'article.deleted',
'supplier.created',
'receipt.matched',
'receipt.confirmed',
@@ -98,6 +99,9 @@ function extractEntityId(payload: Record): string | null {
if (typeof payload.invoiceId === 'string') {
return payload.invoiceId
}
+ if (typeof payload.articleId === 'string') {
+ return payload.articleId
+ }
// For journal_entry.corrected: use the corrected entry's ID
if ('corrected' in payload) {
diff --git a/lib/events/types.ts b/lib/events/types.ts
index 3807bebf..c920fde0 100644
--- a/lib/events/types.ts
+++ b/lib/events/types.ts
@@ -87,6 +87,7 @@ export type CoreEvent =
// Articles (artikelregister)
| { type: 'article.created'; payload: { article: Article; userId: string; companyId: string } }
| { type: 'article.updated'; payload: { article: Article; userId: string; companyId: string } }
+ | { type: 'article.deleted'; payload: { articleId: string; userId: string; companyId: string } }
// Suppliers
| { type: 'supplier.created'; payload: { supplier: Supplier; userId: string; companyId: string } }
// Receipts
@@ -174,7 +175,7 @@ export type CoreEvent =
success: boolean // true iff the tool returned without throwing AND was invoked (not denied)
isError: boolean // matches the JSON-RPC tool-result isError flag returned to the client
errorCode: string | null // structured error code from tool-result.toToolError when applicable
- errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
+ errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
errorMessage: string | null // human-readable error message (truncated to 500 chars), null on success.
// Raw material for clustering real agent failures into curated gotchas:
// errorCode alone can't distinguish "period locked" from "unbalanced".
diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts
index bf3ca2da..b2bcea96 100644
--- a/lib/extensions/_generated/sector-definitions.ts
+++ b/lib/extensions/_generated/sector-definitions.ts
@@ -23,7 +23,7 @@ export const EXTENSION_DEFINITIONS: Record = {
"icon": "Mail",
"dataPattern": "core",
"description": "Skicka fakturor och påminnelser via e-post",
- "longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
+ "longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
"readsCoreTables": [
"invoices",
"customers",
diff --git a/lib/invoices/__tests__/build-credit-note-item.test.ts b/lib/invoices/__tests__/build-credit-note-item.test.ts
new file mode 100644
index 00000000..a6c00d06
--- /dev/null
+++ b/lib/invoices/__tests__/build-credit-note-item.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item'
+import type { InvoiceItem } from '@/types'
+
+function item(overrides: Partial = {}): InvoiceItem {
+ return {
+ id: 'item-1',
+ invoice_id: 'invoice-1',
+ sort_order: 0,
+ description: 'Arbete',
+ quantity: 2,
+ unit: 'tim',
+ unit_price: 1000,
+ line_total: 2000,
+ vat_rate: 25,
+ vat_amount: 500,
+ created_at: '2026-07-14T00:00:00Z',
+ ...overrides,
+ }
+}
+
+describe('buildCreditNoteItem', () => {
+ it('negates amounts and preserves ROT/RUT, account, accrual, and dimension metadata', () => {
+ const result = buildCreditNoteItem('credit-1', item({
+ deduction_type: 'rot',
+ deduction_amount: 600,
+ labor_hours: 2,
+ work_type: 'BYGG',
+ housing_designation: 'Test 1:2',
+ revenue_account: '3041',
+ accrual_period_start: '2026-07-01',
+ accrual_period_end: '2026-12-31',
+ accrual_balance_account: '2970',
+ dimensions: { '6': 'P001' },
+ }))
+
+ expect(result).toMatchObject({
+ invoice_id: 'credit-1',
+ quantity: -2,
+ line_total: -2000,
+ vat_amount: -500,
+ deduction_type: 'rot',
+ deduction_amount: -600,
+ labor_hours: 2,
+ work_type: 'BYGG',
+ housing_designation: 'Test 1:2',
+ revenue_account: '3041',
+ accrual_period_start: '2026-07-01',
+ accrual_period_end: '2026-12-31',
+ accrual_balance_account: '2970',
+ dimensions: { '6': 'P001' },
+ })
+ })
+})
diff --git a/lib/invoices/__tests__/copy-invoice.test.ts b/lib/invoices/__tests__/copy-invoice.test.ts
new file mode 100644
index 00000000..30fa5c93
--- /dev/null
+++ b/lib/invoices/__tests__/copy-invoice.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest'
+import { makeInvoice } from '@/tests/helpers'
+import { buildInvoiceCopyInitial, canCopyInvoice } from '@/lib/invoices/copy-invoice'
+import type { InvoiceItem } from '@/types'
+
+function makeItem(overrides: Partial = {}): InvoiceItem {
+ return {
+ id: 'item-1',
+ invoice_id: 'invoice-1',
+ sort_order: 0,
+ line_type: 'product',
+ description: 'Consulting',
+ quantity: 2,
+ unit: 'tim',
+ unit_price: 1000,
+ line_total: 2000,
+ vat_rate: 25,
+ vat_amount: 500,
+ created_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ }
+}
+
+describe('canCopyInvoice', () => {
+ it.each(['sent', 'paid', 'partially_paid', 'overdue', 'credited'] as const)(
+ 'allows an issued ordinary invoice with status %s',
+ (status) => {
+ expect(canCopyInvoice(makeInvoice({ status }))).toBe(true)
+ },
+ )
+
+ it('rejects drafts, credit notes, other document types, and self-billing invoices', () => {
+ expect(canCopyInvoice(makeInvoice({ status: 'draft' }))).toBe(false)
+ expect(canCopyInvoice(makeInvoice({ status: 'sent', credited_invoice_id: 'invoice-original' }))).toBe(false)
+ expect(canCopyInvoice(makeInvoice({ status: 'sent', document_type: 'proforma' }))).toBe(false)
+ expect(canCopyInvoice(makeInvoice({ status: 'sent', is_self_billed: true }))).toBe(false)
+ })
+})
+
+describe('buildInvoiceCopyInitial', () => {
+ it('copies reusable content and clears lifecycle-specific fields', () => {
+ const source = makeInvoice({
+ id: 'invoice-original',
+ invoice_number: 'F-2026007',
+ status: 'paid',
+ invoice_date: '2026-01-01',
+ due_date: '2026-01-31',
+ delivery_date: '2025-12-20',
+ your_reference: 'Old customer contact',
+ our_reference: 'Seller contact',
+ notes: 'Reusable terms',
+ payment_link_url: 'https://example.com/old-payment',
+ journal_entry_id: 'journal-1',
+ ore_rounding: true,
+ default_dimensions: { '1': 'KS01' },
+ })
+ const first = makeItem({
+ sort_order: 1,
+ article_id: 'article-1',
+ revenue_account: '3041',
+ deduction_type: 'rot',
+ labor_hours: 2,
+ work_type: 'BYGG',
+ housing_designation: 'Old property',
+ apartment_number: '1201',
+ brf_org_number: '5560000000',
+ accrual_period_start: '2026-01-01',
+ accrual_period_end: '2026-06-30',
+ accrual_balance_account: '2970',
+ dimensions: { '6': 'P001' },
+ })
+ const second = makeItem({ id: 'item-2', sort_order: 0, description: 'First row' })
+
+ const copy = buildInvoiceCopyInitial({ ...source, items: [first, second] })
+
+ expect(copy).toMatchObject({
+ source_invoice_number: 'F-2026007',
+ customer_id: source.customer_id,
+ currency: 'SEK',
+ document_type: 'invoice',
+ our_reference: 'Seller contact',
+ notes: 'Reusable terms',
+ ore_rounding: true,
+ default_dimensions: { '1': 'KS01' },
+ })
+ expect(copy.items.map((item) => item.description)).toEqual(['First row', 'Consulting'])
+ expect(copy.items[1]).toMatchObject({
+ article_id: null,
+ revenue_account: '3041',
+ deduction_type: 'rot',
+ labor_hours: 2,
+ work_type: 'BYGG',
+ housing_designation: null,
+ apartment_number: null,
+ brf_org_number: null,
+ accrual_period_start: null,
+ accrual_period_end: null,
+ accrual_balance_account: null,
+ dimensions: { '6': 'P001' },
+ })
+ expect(copy).not.toHaveProperty('invoice_date')
+ expect(copy).not.toHaveProperty('due_date')
+ expect(copy).not.toHaveProperty('your_reference')
+ expect(copy).not.toHaveProperty('payment_link_url')
+ expect(copy).not.toHaveProperty('journal_entry_id')
+ expect(copy).not.toHaveProperty('status')
+ })
+})
diff --git a/lib/invoices/__tests__/credit-note-not-payable.pg.test.ts b/lib/invoices/__tests__/credit-note-not-payable.pg.test.ts
new file mode 100644
index 00000000..08875799
--- /dev/null
+++ b/lib/invoices/__tests__/credit-note-not-payable.pg.test.ts
@@ -0,0 +1,61 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { getPool } from '@/tests/pg/setup'
+import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
+
+async function seedCustomerInvoicePair(): Promise<{
+ originalInvoiceId: string
+ creditNoteId: string
+}> {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ await insertCompanyMember({ companyId, userId })
+
+ const customerId = randomUUID()
+ const originalInvoiceId = randomUUID()
+ const creditNoteId = randomUUID()
+
+ await getPool().query(
+ `INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
+ VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`,
+ [customerId, userId, companyId],
+ )
+ await getPool().query(
+ `INSERT INTO public.invoices
+ (id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
+ currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
+ paid_amount, remaining_amount)
+ VALUES ($1, $2, $3, $4, $5, '2026-07-01', '2026-07-31', 'SEK',
+ 1000, 0, 1000, 'standard_25', 25, 'sent', 0, 1000)`,
+ [originalInvoiceId, userId, companyId, customerId, `F-${originalInvoiceId.slice(0, 8)}`],
+ )
+ await getPool().query(
+ `INSERT INTO public.invoices
+ (id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
+ currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
+ paid_amount, remaining_amount, credited_invoice_id)
+ VALUES ($1, $2, $3, $4, $5, '2026-07-01', '2026-07-31', 'SEK',
+ -1000, 0, -1000, 'standard_25', 25, 'sent', 0, -1000, $6)`,
+ [creditNoteId, userId, companyId, customerId, `KR-${creditNoteId.slice(0, 8)}`, originalInvoiceId],
+ )
+
+ return { originalInvoiceId, creditNoteId }
+}
+
+describe('invoices_credit_note_not_paid constraint', () => {
+ it.each(['paid', 'partially_paid'])('rejects credit-note status %s', async (status) => {
+ const { creditNoteId } = await seedCustomerInvoicePair()
+
+ await expect(
+ getPool().query(`UPDATE public.invoices SET status = $1 WHERE id = $2`, [status, creditNoteId]),
+ ).rejects.toMatchObject({ code: '23514' })
+ })
+
+ it('allows an ordinary customer invoice to be marked paid', async () => {
+ const { originalInvoiceId } = await seedCustomerInvoicePair()
+
+ await expect(
+ getPool().query(`UPDATE public.invoices SET status = 'paid' WHERE id = $1`, [originalInvoiceId]),
+ ).resolves.toMatchObject({ rowCount: 1 })
+ })
+})
diff --git a/lib/invoices/__tests__/credit-note-send-mode.test.ts b/lib/invoices/__tests__/credit-note-send-mode.test.ts
new file mode 100644
index 00000000..762d4ea9
--- /dev/null
+++ b/lib/invoices/__tests__/credit-note-send-mode.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
+
+describe('getCreditNoteSendMode', () => {
+ it('prefers email when delivery is available and the customer has an address', () => {
+ expect(getCreditNoteSendMode({
+ customerHasEmail: true,
+ isSandbox: false,
+ canEmail: true,
+ })).toBe('email')
+ })
+
+ it.each([
+ { customerHasEmail: false, isSandbox: false, canEmail: true },
+ { customerHasEmail: true, isSandbox: true, canEmail: true },
+ { customerHasEmail: true, isSandbox: false, canEmail: false },
+ ])('falls back to manual issuance for $customerHasEmail/$isSandbox/$canEmail', (input) => {
+ expect(getCreditNoteSendMode(input)).toBe('manual')
+ })
+})
diff --git a/lib/invoices/__tests__/invoice-matching.test.ts b/lib/invoices/__tests__/invoice-matching.test.ts
index 21b27071..8e8e3260 100644
--- a/lib/invoices/__tests__/invoice-matching.test.ts
+++ b/lib/invoices/__tests__/invoice-matching.test.ts
@@ -184,6 +184,25 @@ describe('findMatchingInvoices', () => {
expect(result[0].matchReason).toContain('OCR-referens')
})
+ it('never suggests a credit note, even when its OCR reference matches', async () => {
+ const tx = makeTransaction({ amount: 12500, reference: 'KR-F-2024001' })
+ mockResult({
+ data: [
+ makeInvoice({
+ invoice_number: 'KR-F-2024001',
+ status: 'sent',
+ total: -12500,
+ credited_invoice_id: 'original-invoice-1',
+ }),
+ ],
+ error: null,
+ })
+
+ const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
+
+ expect(result).toEqual([])
+ })
+
it('returns immediately on OCR match without further scoring', async () => {
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
mockResult({
diff --git a/lib/invoices/__tests__/is-editable-draft.test.ts b/lib/invoices/__tests__/is-editable-draft.test.ts
index c647b602..3ffb0474 100644
--- a/lib/invoices/__tests__/is-editable-draft.test.ts
+++ b/lib/invoices/__tests__/is-editable-draft.test.ts
@@ -29,4 +29,14 @@ describe('isEditableInvoiceDraft', () => {
isEditableInvoiceDraft({ status: 'draft', journal_entry_id: null, is_self_billed: true }),
).toBe(false)
})
+
+ it('blocks a credit-note draft because it must continue mirroring the original', () => {
+ expect(
+ isEditableInvoiceDraft({
+ status: 'draft',
+ journal_entry_id: null,
+ credited_invoice_id: 'invoice-1',
+ }),
+ ).toBe(false)
+ })
})
diff --git a/lib/invoices/__tests__/issue-credit-note.test.ts b/lib/invoices/__tests__/issue-credit-note.test.ts
new file mode 100644
index 00000000..405df4b1
--- /dev/null
+++ b/lib/invoices/__tests__/issue-credit-note.test.ts
@@ -0,0 +1,219 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { eventBus } from '@/lib/events'
+import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
+import type { Logger } from '@/lib/logger'
+import type { CreditNote } from '@/types'
+
+const mockCreateCreditNoteJournalEntry = vi.fn()
+vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
+ createCreditNoteJournalEntry: (...args: unknown[]) =>
+ mockCreateCreditNoteJournalEntry(...args),
+}))
+
+const mockCancelSchedulesForSource = vi.fn()
+vi.mock('@/lib/bookkeeping/accruals/service', () => ({
+ cancelSchedulesForSource: (...args: unknown[]) =>
+ mockCancelSchedulesForSource(...args),
+}))
+
+import {
+ creditNoteNeedsJournalEntry,
+ issueCreditNote,
+} from '@/lib/invoices/issue-credit-note'
+
+const { supabase, enqueue, reset } = createQueuedMockSupabase()
+const log: Logger = {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ child: vi.fn(),
+}
+
+function makeCreditNote(overrides: Partial = {}) {
+ return {
+ ...makeInvoice({
+ id: 'credit-1',
+ invoice_number: 'KR-F-100',
+ invoice_date: '2026-07-14',
+ status: 'draft',
+ credited_invoice_id: 'invoice-1',
+ subtotal: -1000,
+ vat_amount: -250,
+ total: -1250,
+ ...overrides,
+ }),
+ customer: { name: 'Testkund' },
+ } as CreditNote & { customer: { name: string } }
+}
+
+describe('issueCreditNote', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ eventBus.clear()
+ mockCancelSchedulesForSource.mockResolvedValue({
+ cancelledSchedules: 1,
+ reversedEntries: 0,
+ failedReversals: 0,
+ })
+ })
+
+ it('books an accrual credit note, links it, cancels accruals, and marks the original', async () => {
+ enqueue({ data: null, error: null })
+ enqueue({ data: { voucher_series: 'A', voucher_number: 42 }, error: null })
+ mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'journal-1' })
+ enqueue({ data: [{ id: 'credit-1' }], error: null })
+ enqueue({ data: [{ id: 'invoice-1' }], error: null })
+ const emitSpy = vi.spyOn(eventBus, 'emit')
+
+ const result = await issueCreditNote({
+ supabase: supabase as never,
+ companyId: 'company-1',
+ userId: 'user-1',
+ creditNote: makeCreditNote(),
+ originalInvoice: {
+ id: 'invoice-1',
+ invoice_number: 'F-100',
+ status: 'sent',
+ journal_entry_id: 'original-journal-1',
+ },
+ entityType: 'enskild_firma',
+ accountingMethod: 'accrual',
+ log,
+ })
+
+ expect(result).toEqual({
+ complete: true,
+ journalEntryId: 'journal-1',
+ journalEntryRequired: true,
+ repairRequired: false,
+ failures: [],
+ })
+ expect(mockCreateCreditNoteJournalEntry).toHaveBeenCalledWith(
+ expect.anything(),
+ 'company-1',
+ 'user-1',
+ expect.objectContaining({ id: 'credit-1', status: 'sent' }),
+ 'enskild_firma',
+ 'Testkund',
+ 'A-42',
+ )
+ expect(mockCancelSchedulesForSource).toHaveBeenCalledWith(
+ expect.anything(),
+ 'company-1',
+ 'user-1',
+ { invoiceId: 'invoice-1' },
+ { reversalDate: '2026-07-14' },
+ )
+ expect(emitSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'credit_note.created' }),
+ )
+ })
+
+ it('marks and emits a cash-method credit note without creating a journal entry', async () => {
+ enqueue({ data: [{ id: 'invoice-1' }], error: null })
+
+ const result = await issueCreditNote({
+ supabase: supabase as never,
+ companyId: 'company-1',
+ userId: 'user-1',
+ creditNote: makeCreditNote(),
+ originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
+ entityType: 'enskild_firma',
+ accountingMethod: 'cash',
+ log,
+ })
+
+ expect(result).toEqual({
+ complete: true,
+ journalEntryId: null,
+ journalEntryRequired: false,
+ repairRequired: false,
+ failures: [],
+ })
+ expect(mockCreateCreditNoteJournalEntry).not.toHaveBeenCalled()
+ expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
+ })
+
+ it('does not cancel accrual schedules when the credit journal entry fails', async () => {
+ enqueue({ data: null, error: null })
+ mockCreateCreditNoteJournalEntry.mockRejectedValue(new Error('Perioden är låst'))
+
+ const result = await issueCreditNote({
+ supabase: supabase as never,
+ companyId: 'company-1',
+ userId: 'user-1',
+ creditNote: makeCreditNote(),
+ originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
+ entityType: 'enskild_firma',
+ accountingMethod: 'accrual',
+ log,
+ })
+
+ expect(result.journalEntryId).toBeNull()
+ expect(result.complete).toBe(false)
+ expect(result.failures).toEqual([
+ { step: 'journal_entry', reason: 'Perioden är låst' },
+ ])
+ expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
+ })
+
+ it('books a paid cash-method original before marking it credited', async () => {
+ enqueue({ data: null, error: null })
+ mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'journal-cash' })
+ enqueue({ data: [{ id: 'credit-1' }], error: null })
+ enqueue({ data: [{ id: 'invoice-1' }], error: null })
+
+ const result = await issueCreditNote({
+ supabase: supabase as never,
+ companyId: 'company-1',
+ userId: 'user-1',
+ creditNote: makeCreditNote(),
+ originalInvoice: {
+ id: 'invoice-1',
+ invoice_number: 'F-100',
+ status: 'paid',
+ paid_at: '2026-07-01T00:00:00Z',
+ },
+ entityType: 'enskild_firma',
+ accountingMethod: 'cash',
+ log,
+ })
+
+ expect(result.complete).toBe(true)
+ expect(result.journalEntryId).toBe('journal-cash')
+ expect(mockCreateCreditNoteJournalEntry).toHaveBeenCalledOnce()
+ expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
+ })
+
+ it('detects when cash-method credit notes need a reversal voucher', () => {
+ const original = { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' }
+ expect(creditNoteNeedsJournalEntry('cash', original)).toBe(false)
+ expect(creditNoteNeedsJournalEntry('cash', { ...original, status: 'paid' })).toBe(true)
+ expect(creditNoteNeedsJournalEntry('cash', { ...original, paid_amount: 100 })).toBe(true)
+ expect(creditNoteNeedsJournalEntry('accrual', original)).toBe(true)
+ })
+
+ it('reuses the posted voucher that wins a concurrent create race', async () => {
+ enqueue({ data: null, error: null })
+ mockCreateCreditNoteJournalEntry.mockRejectedValue(new Error('duplicate source'))
+ enqueue({ data: { id: 'journal-winner' }, error: null })
+ enqueue({ data: [{ id: 'credit-1' }], error: null })
+ enqueue({ data: [{ id: 'invoice-1' }], error: null })
+
+ const result = await issueCreditNote({
+ supabase: supabase as never,
+ companyId: 'company-1',
+ userId: 'user-1',
+ creditNote: makeCreditNote(),
+ originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
+ entityType: 'enskild_firma',
+ accountingMethod: 'accrual',
+ log,
+ })
+
+ expect(result.complete).toBe(true)
+ expect(result.journalEntryId).toBe('journal-winner')
+ expect(result.failures).toEqual([])
+ })
+})
diff --git a/lib/invoices/__tests__/reminder-days.pg.test.ts b/lib/invoices/__tests__/reminder-days.pg.test.ts
new file mode 100644
index 00000000..cdeb5968
--- /dev/null
+++ b/lib/invoices/__tests__/reminder-days.pg.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest'
+import { getPool } from '@/tests/pg/setup'
+import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
+
+async function seedCompanySettings(): Promise {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ await insertCompanyMember({ companyId, userId })
+ await getPool().query(
+ `INSERT INTO public.company_settings (user_id, company_id)
+ VALUES ($1, $2)`,
+ [userId, companyId],
+ )
+ return companyId
+}
+
+describe('company_settings reminder day constraints', () => {
+ it('uses the legacy 15, 30, and 45 day defaults', async () => {
+ const companyId = await seedCompanySettings()
+
+ const result = await getPool().query(
+ `SELECT reminder_days_level_1, reminder_days_level_2, reminder_days_level_3
+ FROM public.company_settings
+ WHERE company_id = $1`,
+ [companyId],
+ )
+
+ expect(result.rows[0]).toMatchObject({
+ reminder_days_level_1: 15,
+ reminder_days_level_2: 30,
+ reminder_days_level_3: 45,
+ })
+ })
+
+ it('accepts a strictly increasing custom schedule', async () => {
+ const companyId = await seedCompanySettings()
+
+ await expect(
+ getPool().query(
+ `UPDATE public.company_settings
+ SET reminder_days_level_1 = 7,
+ reminder_days_level_2 = 21,
+ reminder_days_level_3 = 35
+ WHERE company_id = $1`,
+ [companyId],
+ ),
+ ).resolves.toMatchObject({ rowCount: 1 })
+ })
+
+ it.each([
+ [30, 20, 45],
+ [15, 15, 45],
+ [0, 30, 45],
+ [15, 30, 366],
+ ])('rejects invalid schedule %s, %s, %s', async (level1, level2, level3) => {
+ const companyId = await seedCompanySettings()
+
+ await expect(
+ getPool().query(
+ `UPDATE public.company_settings
+ SET reminder_days_level_1 = $1,
+ reminder_days_level_2 = $2,
+ reminder_days_level_3 = $3
+ WHERE company_id = $4`,
+ [level1, level2, level3, companyId],
+ ),
+ ).rejects.toMatchObject({ code: '23514' })
+ })
+})
diff --git a/lib/invoices/__tests__/reminder-processor.test.ts b/lib/invoices/__tests__/reminder-processor.test.ts
index adfc2f10..5f7a7d3d 100644
--- a/lib/invoices/__tests__/reminder-processor.test.ts
+++ b/lib/invoices/__tests__/reminder-processor.test.ts
@@ -39,6 +39,7 @@ import {
determineReminderLevel,
calculateDaysOverdue,
} from '../reminder-processor'
+import { getReminderDaysConfig } from '@/lib/email/reminder-templates'
describe('determineReminderLevel', () => {
it('returns null below the level-1 threshold', () => {
@@ -60,6 +61,37 @@ describe('determineReminderLevel', () => {
it('returns null when all levels have been sent', () => {
expect(determineReminderLevel(60, [1, 2, 3])).toBeNull()
})
+
+ it('uses a company-specific schedule', () => {
+ const config = { 1: 7, 2: 21, 3: 35 } as const
+
+ expect(determineReminderLevel(6, [], config)).toBeNull()
+ expect(determineReminderLevel(7, [], config)).toBe(1)
+ expect(determineReminderLevel(21, [1], config)).toBe(2)
+ expect(determineReminderLevel(35, [1, 2], config)).toBe(3)
+ })
+})
+
+describe('getReminderDaysConfig', () => {
+ it('returns the existing defaults when no company settings are supplied', () => {
+ expect(getReminderDaysConfig()).toEqual({ 1: 15, 2: 30, 3: 45 })
+ })
+
+ it('returns configured company thresholds', () => {
+ expect(getReminderDaysConfig({
+ reminder_days_level_1: 5,
+ reminder_days_level_2: 10,
+ reminder_days_level_3: 20,
+ })).toEqual({ 1: 5, 2: 10, 3: 20 })
+ })
+
+ it('falls back to defaults for an invalid stored schedule', () => {
+ expect(getReminderDaysConfig({
+ reminder_days_level_1: 30,
+ reminder_days_level_2: 20,
+ reminder_days_level_3: 45,
+ })).toEqual({ 1: 15, 2: 30, 3: 45 })
+ })
})
describe('calculateDaysOverdue', () => {
diff --git a/lib/invoices/__tests__/settle-invoice-payment.test.ts b/lib/invoices/__tests__/settle-invoice-payment.test.ts
index 42d1bd2e..80513411 100644
--- a/lib/invoices/__tests__/settle-invoice-payment.test.ts
+++ b/lib/invoices/__tests__/settle-invoice-payment.test.ts
@@ -49,6 +49,28 @@ describe('settleInvoicePayment', () => {
vi.mocked(createInvoiceCashEntry).mockResolvedValue({ id: 'je-2' } as never)
})
+ it('rejects credit notes before creating a journal entry or updating state', async () => {
+ const { supabase } = createQueuedMockSupabase()
+ const result = await settleInvoicePayment(
+ supabase as unknown as SupabaseClient,
+ 'company-1',
+ 'user-1',
+ {
+ ...BASE_PARAMS,
+ invoice: payableInvoice({ credited_invoice_id: 'original-invoice-1' }),
+ },
+ )
+
+ expect(result).toEqual({
+ ok: false,
+ code: 'INVOICE_PAID_NOT_PAYABLE',
+ details: { reason: 'credit_note' },
+ })
+ expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled()
+ expect(vi.mocked(createInvoiceCashEntry)).not.toHaveBeenCalled()
+ expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
+ })
+
it('books via the payment entry and forwards the settlement account', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
diff --git a/lib/invoices/build-credit-note-item.ts b/lib/invoices/build-credit-note-item.ts
new file mode 100644
index 00000000..45584f27
--- /dev/null
+++ b/lib/invoices/build-credit-note-item.ts
@@ -0,0 +1,31 @@
+import type { InvoiceItem } from '@/types'
+
+export function buildCreditNoteItem(invoiceId: string, item: InvoiceItem) {
+ return {
+ invoice_id: invoiceId,
+ sort_order: item.sort_order,
+ line_type: item.line_type ?? 'product',
+ description: item.description,
+ quantity: -Math.abs(item.quantity),
+ unit: item.unit,
+ unit_price: item.unit_price,
+ line_total: -Math.abs(item.line_total),
+ vat_rate: item.vat_rate ?? 0,
+ vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
+ revenue_account: item.revenue_account ?? null,
+ article_id: item.article_id ?? null,
+ deduction_type: item.deduction_type ?? null,
+ deduction_amount: item.deduction_amount
+ ? -Math.abs(item.deduction_amount)
+ : 0,
+ labor_hours: item.labor_hours ?? null,
+ work_type: item.work_type ?? null,
+ housing_designation: item.housing_designation ?? null,
+ apartment_number: item.apartment_number ?? null,
+ brf_org_number: item.brf_org_number ?? null,
+ accrual_period_start: item.accrual_period_start ?? null,
+ accrual_period_end: item.accrual_period_end ?? null,
+ accrual_balance_account: item.accrual_balance_account ?? null,
+ dimensions: item.dimensions ?? {},
+ }
+}
diff --git a/lib/invoices/copy-invoice.ts b/lib/invoices/copy-invoice.ts
new file mode 100644
index 00000000..4b972d0e
--- /dev/null
+++ b/lib/invoices/copy-invoice.ts
@@ -0,0 +1,108 @@
+import type {
+ Currency,
+ Invoice,
+ InvoiceDocumentType,
+ InvoiceItem,
+ InvoiceStatus,
+} from '@/types'
+
+const COPYABLE_STATUSES: ReadonlySet = new Set([
+ 'sent',
+ 'paid',
+ 'partially_paid',
+ 'overdue',
+ 'credited',
+])
+
+export type InvoiceCopySource = Invoice & { items: InvoiceItem[] }
+
+export interface InvoiceCopyItem {
+ line_type: 'product' | 'text'
+ description: string
+ quantity: number
+ unit: string
+ unit_price: number
+ vat_rate: number
+ article_id: null
+ revenue_account: string | null
+ deduction_type: 'rot' | 'rut' | null
+ labor_hours: number | null
+ work_type: string | null
+ housing_designation: null
+ apartment_number: null
+ brf_org_number: null
+ accrual_period_start: null
+ accrual_period_end: null
+ accrual_balance_account: null
+ dimensions: Record | null
+}
+
+export interface InvoiceCopyInitial {
+ source_invoice_number: string
+ customer_id: string
+ currency: Currency
+ document_type: InvoiceDocumentType
+ our_reference: string
+ notes: string
+ ore_rounding: boolean | null
+ default_dimensions: Record
+ items: InvoiceCopyItem[]
+}
+
+export function canCopyInvoice(
+ invoice: Pick,
+): boolean {
+ return (
+ invoice.document_type === 'invoice' &&
+ !invoice.credited_invoice_id &&
+ !invoice.is_self_billed &&
+ COPYABLE_STATUSES.has(invoice.status)
+ )
+}
+
+/**
+ * Builds a safe starting point for a new invoice draft.
+ *
+ * The copied data is limited to reusable commercial content. Identity,
+ * lifecycle, payment, bookkeeping, date, accrual, and recipient-specific
+ * ROT/RUT fields are deliberately absent or cleared.
+ */
+export function buildInvoiceCopyInitial(source: InvoiceCopySource): InvoiceCopyInitial {
+ return {
+ source_invoice_number: source.invoice_number ?? '',
+ customer_id: source.customer_id,
+ currency: source.currency,
+ document_type: 'invoice',
+ our_reference: source.our_reference ?? '',
+ notes: source.notes ?? '',
+ ore_rounding: source.ore_rounding,
+ default_dimensions: source.default_dimensions ?? {},
+ items: [...source.items]
+ .sort((a, b) => a.sort_order - b.sort_order)
+ .map((item) => ({
+ line_type: item.line_type ?? 'product',
+ description: item.description,
+ quantity: item.quantity,
+ unit: item.unit,
+ unit_price: item.unit_price,
+ vat_rate: item.vat_rate ?? 25,
+ // A copied line keeps the frozen description and price, but is not
+ // linked to a possibly changed or archived article preset.
+ article_id: null,
+ revenue_account: item.revenue_account ?? null,
+ deduction_type: item.deduction_type ?? null,
+ labor_hours: item.labor_hours ?? null,
+ work_type: item.work_type ?? null,
+ housing_designation: null,
+ apartment_number: null,
+ brf_org_number: null,
+ // Accrual dates belong to the original accounting period.
+ accrual_period_start: null,
+ accrual_period_end: null,
+ accrual_balance_account: null,
+ dimensions: item.dimensions && Object.keys(item.dimensions).length > 0
+ ? item.dimensions
+ : null,
+ })),
+ }
+}
diff --git a/lib/invoices/credit-note-send-mode.ts b/lib/invoices/credit-note-send-mode.ts
new file mode 100644
index 00000000..3852206d
--- /dev/null
+++ b/lib/invoices/credit-note-send-mode.ts
@@ -0,0 +1,9 @@
+export function getCreditNoteSendMode(input: {
+ customerHasEmail: boolean
+ isSandbox: boolean
+ canEmail: boolean
+}): 'email' | 'manual' {
+ return input.customerHasEmail && !input.isSandbox && input.canEmail
+ ? 'email'
+ : 'manual'
+}
diff --git a/lib/invoices/invoice-matching.ts b/lib/invoices/invoice-matching.ts
index c33b2d3c..b518a9b6 100644
--- a/lib/invoices/invoice-matching.ts
+++ b/lib/invoices/invoice-matching.ts
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
+import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { Invoice, Transaction, Customer } from '@/types'
export interface InvoiceMatch {
@@ -130,17 +131,19 @@ export async function findMatchingInvoices(
}
// Query unpaid invoices (sent or overdue) with customer info
- const { data: invoices, error } = await supabase
- .from('invoices')
- .select(`
- *,
- customer:customers(*)
- `)
- .eq('company_id', companyId)
- .in('status', ['sent', 'overdue', 'partially_paid'])
- .order('due_date', { ascending: true })
-
- if (error || !invoices) {
+ let invoices: Array
+ try {
+ invoices = await fetchAllRows(({ from, to }) =>
+ supabase
+ .from('invoices')
+ .select('*, customer:customers(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
+ .eq('company_id', companyId)
+ .is('credited_invoice_id', null)
+ .in('status', ['sent', 'overdue', 'partially_paid'])
+ .order('id', { ascending: true })
+ .range(from, to),
+ )
+ } catch {
// Failed to fetch invoices: return empty matches
return []
}
@@ -149,22 +152,36 @@ export async function findMatchingInvoices(
// attached but whose status leaked (still 'sent'/'overdue'). Partially-paid
// invoices can legitimately take more payments, so they pass through.
// Without this, a status leak would double-book the receipt.
- const fullCandidateIds = invoices
+ const payableInvoices = invoices.filter(
+ (invoice) => {
+ const creditNotes = (invoice as Invoice & {
+ credit_notes?: Array<{ status: string; creation_complete?: boolean }>
+ }).credit_notes ?? []
+ return !invoice.credited_invoice_id && !creditNotes.some(
+ (creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
+ )
+ },
+ )
+ const fullCandidateIds = payableInvoices
.filter((inv) => inv.status === 'sent' || inv.status === 'overdue')
.map((inv) => inv.id as string)
const paidIds = new Set()
if (fullCandidateIds.length > 0) {
- const { data: paymentRows } = await supabase
- .from('invoice_payments')
- .select('invoice_id')
- .eq('company_id', companyId)
- .in('invoice_id', fullCandidateIds)
- .not('journal_entry_id', 'is', null)
- for (const row of paymentRows ?? []) {
+ const paymentRows = await fetchAllRows<{ id: string; invoice_id: string }>(({ from, to }) =>
+ supabase
+ .from('invoice_payments')
+ .select('id, invoice_id')
+ .eq('company_id', companyId)
+ .in('invoice_id', fullCandidateIds)
+ .not('journal_entry_id', 'is', null)
+ .order('id', { ascending: true })
+ .range(from, to),
+ )
+ for (const row of paymentRows) {
paidIds.add((row as { invoice_id: string }).invoice_id)
}
}
- const filteredInvoices = invoices.filter((inv) => !paidIds.has(inv.id as string))
+ const filteredInvoices = payableInvoices.filter((inv) => !paidIds.has(inv.id as string))
if (filteredInvoices.length === 0) {
return []
}
diff --git a/lib/invoices/is-editable-draft.ts b/lib/invoices/is-editable-draft.ts
index c6cfd2d7..8d1663f4 100644
--- a/lib/invoices/is-editable-draft.ts
+++ b/lib/invoices/is-editable-draft.ts
@@ -4,7 +4,9 @@
* entry is created when the invoice is sent (mark-sent / send) or, for
* kontantmetoden, at payment; once one exists, BFL immutability applies and the
* invoice must be corrected with a credit note instead. A self-billed invoice we
- * received is the counterparty's document: never editable here.
+ * received is the counterparty's document: never editable here. Credit-note
+ * drafts mirror an issued invoice and must not be changed into a different
+ * correction after creation.
*
* This is the single source of truth for that predicate. The PATCH route
* (app/api/invoices/[id]/route.ts) enforces it server-side; the detail and edit
@@ -15,6 +17,12 @@ export function isEditableInvoiceDraft(invoice: {
status: string
journal_entry_id?: string | null
is_self_billed?: boolean | null
+ credited_invoice_id?: string | null
}): boolean {
- return invoice.status === 'draft' && !invoice.journal_entry_id && !invoice.is_self_billed
+ return (
+ invoice.status === 'draft' &&
+ !invoice.journal_entry_id &&
+ !invoice.is_self_billed &&
+ !invoice.credited_invoice_id
+ )
}
diff --git a/lib/invoices/issue-credit-note.ts b/lib/invoices/issue-credit-note.ts
new file mode 100644
index 00000000..4f476b11
--- /dev/null
+++ b/lib/invoices/issue-credit-note.ts
@@ -0,0 +1,280 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { eventBus } from '@/lib/events'
+import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
+import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
+import type { Logger } from '@/lib/logger'
+import type { AccountingMethod, CreditNote, EntityType } from '@/types'
+
+export interface CreditNoteOriginalInvoice {
+ id: string
+ invoice_number: string | null
+ status: string
+ journal_entry_id?: string | null
+ paid_at?: string | null
+ paid_amount?: number | null
+ total?: number | null
+}
+
+export interface CreditNoteIssueFailure {
+ step: 'journal_entry' | 'journal_link' | 'accrual_schedules' | 'original_status'
+ reason: string
+}
+
+interface IssueCreditNoteInput {
+ supabase: SupabaseClient
+ companyId: string
+ userId: string
+ creditNote: CreditNote & { customer?: { name?: string | null } | null }
+ originalInvoice: CreditNoteOriginalInvoice
+ entityType: EntityType
+ accountingMethod: AccountingMethod
+ log: Logger
+}
+
+export interface IssueCreditNoteResult {
+ complete: boolean
+ journalEntryId: string | null
+ journalEntryRequired: boolean
+ repairRequired: boolean
+ failures: CreditNoteIssueFailure[]
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : 'Okänt fel'
+}
+
+/**
+ * Faktureringsmetoden always books the credit on issue. Kontantmetoden only
+ * books it when the original sale has already reached the ledger, for example
+ * through a completed payment or a year-end receivable entry.
+ */
+export function creditNoteNeedsJournalEntry(
+ accountingMethod: AccountingMethod,
+ originalInvoice: CreditNoteOriginalInvoice,
+): boolean {
+ return (
+ accountingMethod === 'accrual' ||
+ !!originalInvoice.journal_entry_id ||
+ originalInvoice.status === 'paid' ||
+ !!originalInvoice.paid_at ||
+ Math.abs(originalInvoice.paid_amount ?? 0) > 0
+ )
+}
+
+async function getOriginalVoucherRef(
+ supabase: SupabaseClient,
+ companyId: string,
+ journalEntryId: string | null | undefined,
+ log: Logger,
+): Promise {
+ if (!journalEntryId) return undefined
+
+ const { data, error } = await supabase
+ .from('journal_entries')
+ .select('voucher_series, voucher_number')
+ .eq('id', journalEntryId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (error) {
+ log.warn('failed to load original voucher reference for credit note', error)
+ return undefined
+ }
+
+ if (!data?.voucher_series || data.voucher_number == null) return undefined
+ return `${data.voucher_series}-${data.voucher_number}`
+}
+
+async function findExistingCreditJournalEntry(
+ supabase: SupabaseClient,
+ companyId: string,
+ creditNote: CreditNote,
+): Promise {
+ if (creditNote.journal_entry_id) return creditNote.journal_entry_id
+
+ const { data, error } = await supabase
+ .from('journal_entries')
+ .select('id')
+ .eq('company_id', companyId)
+ .eq('source_type', 'credit_note')
+ .eq('source_id', creditNote.id)
+ .eq('status', 'posted')
+ .maybeSingle()
+
+ if (error) throw error
+ return data?.id ?? null
+}
+
+/**
+ * Completes the accounting side of credit-note issuance after the caller has
+ * won the draft-to-sent compare-and-set. Every step is idempotent so a sent
+ * credit note with incomplete bookkeeping can be repaired without creating a
+ * second immutable voucher.
+ */
+export async function issueCreditNote(input: IssueCreditNoteInput): Promise {
+ const {
+ supabase,
+ companyId,
+ userId,
+ creditNote,
+ originalInvoice,
+ entityType,
+ accountingMethod,
+ log,
+ } = input
+ const failures: CreditNoteIssueFailure[] = []
+ const journalEntryRequired = creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
+ let journalEntryId: string | null = null
+ const issuedCreditNote = { ...creditNote, status: 'sent' as const }
+
+ if (journalEntryRequired) {
+ try {
+ journalEntryId = await findExistingCreditJournalEntry(
+ supabase,
+ companyId,
+ issuedCreditNote,
+ )
+
+ if (!journalEntryId) {
+ const originalVoucherRef = await getOriginalVoucherRef(
+ supabase,
+ companyId,
+ originalInvoice.journal_entry_id,
+ log,
+ )
+ const journalEntry = await createCreditNoteJournalEntry(
+ supabase,
+ companyId,
+ userId,
+ issuedCreditNote,
+ entityType,
+ creditNote.customer?.name ?? undefined,
+ originalVoucherRef,
+ )
+ journalEntryId = journalEntry?.id ?? null
+ }
+ } catch (error) {
+ // A concurrent issuer may have won the unique posted-source guard after
+ // our initial lookup. Re-read and reuse that immutable voucher.
+ try {
+ journalEntryId = await findExistingCreditJournalEntry(
+ supabase,
+ companyId,
+ issuedCreditNote,
+ )
+ } catch (recoveryError) {
+ log.error('failed to recover credit note journal entry after create conflict', recoveryError, {
+ creditNoteId: creditNote.id,
+ })
+ }
+ if (!journalEntryId) {
+ log.error('failed to create or recover credit note journal entry on issue', error, {
+ creditNoteId: creditNote.id,
+ })
+ failures.push({ step: 'journal_entry', reason: errorMessage(error) })
+ }
+ }
+
+ if (!journalEntryId) {
+ if (failures.length === 0) {
+ failures.push({
+ step: 'journal_entry',
+ reason: 'Ingen öppen bokföringsperiod hittades för kreditfakturans datum.',
+ })
+ }
+ return { complete: false, journalEntryId, journalEntryRequired, repairRequired: false, failures }
+ }
+
+ if (creditNote.journal_entry_id !== journalEntryId) {
+ const { data: linkedRows, error: linkError } = await supabase
+ .from('invoices')
+ .update({ journal_entry_id: journalEntryId })
+ .eq('id', creditNote.id)
+ .eq('company_id', companyId)
+ .eq('status', 'sent')
+ .select('id')
+
+ if (linkError || !linkedRows || linkedRows.length === 0) {
+ log.error('failed to link credit note to journal entry', linkError ?? undefined, {
+ creditNoteId: creditNote.id,
+ journalEntryId,
+ })
+ failures.push({
+ step: 'journal_link',
+ reason: linkError?.message ?? 'Kreditfakturan kunde inte kopplas till verifikatet.',
+ })
+ return { complete: false, journalEntryId, journalEntryRequired, repairRequired: true, failures }
+ }
+ }
+
+ if (accountingMethod === 'accrual') {
+ try {
+ const cancelResult = await cancelSchedulesForSource(
+ supabase,
+ companyId,
+ userId,
+ { invoiceId: originalInvoice.id },
+ { reversalDate: creditNote.invoice_date },
+ )
+ if (cancelResult.failedReversals > 0) {
+ failures.push({
+ step: 'accrual_schedules',
+ reason:
+ 'En eller flera periodiseringsverifikat kunde inte vändas. ' +
+ 'Kontrollera Bokföring > Periodiseringar.',
+ })
+ }
+ } catch (error) {
+ log.warn('failed to cancel accrual schedules for credited invoice', error)
+ failures.push({ step: 'accrual_schedules', reason: errorMessage(error) })
+ }
+
+ if (failures.length > 0) {
+ return { complete: false, journalEntryId, journalEntryRequired, repairRequired: true, failures }
+ }
+ }
+ }
+
+ let originalStatusChanged = false
+ if (originalInvoice.status !== 'credited') {
+ const { data: updatedOriginal, error: originalStatusError } = await supabase
+ .from('invoices')
+ .update({ status: 'credited' })
+ .eq('id', originalInvoice.id)
+ .eq('company_id', companyId)
+ .in('status', ['sent', 'paid', 'overdue'])
+ .select('id')
+
+ if (originalStatusError || !updatedOriginal || updatedOriginal.length === 0) {
+ log.error('failed to mark original invoice as credited', originalStatusError ?? undefined, {
+ originalInvoiceId: originalInvoice.id,
+ creditNoteId: creditNote.id,
+ })
+ failures.push({
+ step: 'original_status',
+ reason: originalStatusError?.message ?? 'Originalfakturan kunde inte markeras som krediterad.',
+ })
+ return {
+ complete: false,
+ journalEntryId,
+ journalEntryRequired,
+ repairRequired: journalEntryRequired && !!journalEntryId,
+ failures,
+ }
+ }
+ originalStatusChanged = true
+ }
+
+ if (originalStatusChanged) {
+ try {
+ await eventBus.emit({
+ type: 'credit_note.created',
+ payload: { creditNote: issuedCreditNote, companyId, userId },
+ })
+ } catch (error) {
+ log.warn('credit_note.created emit failed after completed issuance', error)
+ }
+ }
+
+ return { complete: true, journalEntryId, journalEntryRequired, repairRequired: false, failures }
+}
diff --git a/lib/invoices/reminder-processor.ts b/lib/invoices/reminder-processor.ts
index e024a3d2..50efd4a3 100644
--- a/lib/invoices/reminder-processor.ts
+++ b/lib/invoices/reminder-processor.ts
@@ -4,7 +4,8 @@ import {
generateReminderEmailHtml,
generateReminderEmailText,
generateReminderEmailSubject,
- getReminderDaysConfig
+ getReminderDaysConfig,
+ type ReminderDaysConfig,
} from '@/lib/email/reminder-templates'
import { calculateLatePaymentInterest } from '@/lib/invoices/late-payment-interest'
import { createReminderFeeEntry } from '@/lib/bookkeeping/reminder-fee-entries'
@@ -49,21 +50,19 @@ export interface ProcessRemindersResult {
*/
export function determineReminderLevel(
daysOverdue: number,
- existingLevels: number[]
+ existingLevels: number[],
+ config: ReminderDaysConfig = getReminderDaysConfig(),
): 1 | 2 | 3 | null {
- const config = getReminderDaysConfig()
-
- // Check level 3 (45 days)
+ // Check the highest eligible level first, preserving the existing behavior
+ // when a previous cron run was missed.
if (daysOverdue >= config[3] && !existingLevels.includes(3)) {
return 3
}
- // Check level 2 (30 days)
if (daysOverdue >= config[2] && !existingLevels.includes(2)) {
return 2
}
- // Check level 1 (15 days)
if (daysOverdue >= config[1] && !existingLevels.includes(1)) {
return 1
}
@@ -146,12 +145,11 @@ export async function sendReminder(
export async function processOverdueReminders(): Promise {
const supabase = createServiceClient()
const results: ReminderResult[] = []
- const config = getReminderDaysConfig()
- // Find all sent invoices that are past due date (at least 15 days overdue)
- const minOverdueDays = config[1]
+ // Company schedules can start as early as one day overdue. Fetch that
+ // bounded candidate set, then apply each company's thresholds below.
const cutoffDate = new Date()
- cutoffDate.setDate(cutoffDate.getDate() - minOverdueDays)
+ cutoffDate.setDate(cutoffDate.getDate() - 1)
// Positive allowlist: inherently excludes 'paid', 'partially_paid', 'cancelled', 'credited'.
// Including 'overdue' ensures level-2 / level-3 reminders re-fire after the first reminder
@@ -160,7 +158,8 @@ export async function processOverdueReminders(): Promise
.from('invoices')
.select(`
*,
- customer:customers(*)
+ customer:customers(*),
+ credit_notes:invoices!credited_invoice_id(id, status, creation_complete)
`)
.in('status', ['sent', 'overdue'])
.is('credited_invoice_id', null)
@@ -181,6 +180,17 @@ export async function processOverdueReminders(): Promise
// Process each invoice
for (const invoice of overdueInvoices) {
+ const activeCreditNotes = ((invoice as { credit_notes?: Array<{
+ status: string
+ creation_complete?: boolean
+ }> }).credit_notes ?? []).filter(
+ (creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
+ )
+ if (activeCreditNotes.length > 0) {
+ log.info(`Skipping invoice ${invoice.invoice_number}: active credit note exists`)
+ continue
+ }
+
const customer = invoice.customer as Customer
// Skip if customer has no email
@@ -209,13 +219,6 @@ export async function processOverdueReminders(): Promise
const existingLevels = existingReminders?.map(r => r.reminder_level) || []
const daysOverdue = calculateDaysOverdue(invoice.due_date)
- const reminderLevel = determineReminderLevel(daysOverdue, existingLevels)
-
- // Skip if no reminder needed
- if (!reminderLevel) {
- log.info(`Skipping invoice ${invoice.invoice_number}: no reminder needed (${daysOverdue} days overdue, existing levels: ${existingLevels.join(', ')})`)
- continue
- }
// Get company settings for this user
const { data: company, error: companyError } = await supabase
@@ -226,14 +229,17 @@ export async function processOverdueReminders(): Promise
if (companyError || !company) {
log.error(`Skipping invoice ${invoice.invoice_number}: company settings not found`)
- results.push({
- invoiceId: invoice.id,
- invoiceNumber: invoice.invoice_number,
- customerEmail: customer.email,
- reminderLevel,
- success: false,
- error: 'Company settings not found'
- })
+ const fallbackLevel = determineReminderLevel(daysOverdue, existingLevels)
+ if (fallbackLevel) {
+ results.push({
+ invoiceId: invoice.id,
+ invoiceNumber: invoice.invoice_number,
+ customerEmail: customer.email,
+ reminderLevel: fallbackLevel,
+ success: false,
+ error: 'Company settings not found',
+ })
+ }
continue
}
@@ -243,16 +249,35 @@ export async function processOverdueReminders(): Promise
continue
}
+ const reminderConfig = getReminderDaysConfig(company as CompanySettings)
+ const reminderLevel = determineReminderLevel(daysOverdue, existingLevels, reminderConfig)
+
+ if (!reminderLevel) {
+ log.info(`Skipping invoice ${invoice.invoice_number}: no reminder needed (${daysOverdue} days overdue, existing levels: ${existingLevels.join(', ')})`)
+ continue
+ }
+
// Race-window guard: re-check invoice status immediately before sending.
// The cron runs at 08:00; a payment match arriving during the run shouldn't
// produce a reminder for an already-paid invoice.
const { data: currentInvoice } = await supabase
.from('invoices')
- .select('status')
+ .select('status, credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
.eq('id', invoice.id)
+ .eq('company_id', invoice.company_id)
.single()
- if (!currentInvoice || !['sent', 'overdue'].includes(currentInvoice.status as string)) {
+ const currentCreditNotes = ((currentInvoice as { credit_notes?: Array<{
+ status: string
+ creation_complete?: boolean
+ }> } | null)?.credit_notes ?? []).filter(
+ (creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
+ )
+ if (
+ !currentInvoice ||
+ !['sent', 'overdue'].includes(currentInvoice.status as string) ||
+ currentCreditNotes.length > 0
+ ) {
log.info(`Skipping invoice ${invoice.invoice_number}: status changed to ${currentInvoice?.status ?? 'unknown'} mid-run`)
continue
}
diff --git a/lib/invoices/settle-invoice-payment.ts b/lib/invoices/settle-invoice-payment.ts
index 8e6f324b..fed207cf 100644
--- a/lib/invoices/settle-invoice-payment.ts
+++ b/lib/invoices/settle-invoice-payment.ts
@@ -81,6 +81,7 @@ export type SettleInvoicePaymentResult =
| { ok: false; code: 'INVOICE_PAID_LINES_UNBALANCED'; details: Record }
| { ok: false; code: 'INVOICE_PAID_NO_FISCAL_PERIOD'; details: Record }
| { ok: false; code: 'INVOICE_PAID_BOOK_FAILED'; details: Record }
+ | { ok: false; code: 'INVOICE_PAID_NOT_PAYABLE'; details: Record }
| { ok: false; code: 'INVOICE_PAID_RACE' }
| { ok: false; code: 'BOOKKEEPING_ERROR'; error: unknown }
| { ok: false; code: 'UPDATE_FAILED'; error: unknown }
@@ -102,6 +103,14 @@ export async function settleInvoicePayment(
settlementAccountNumber,
} = params
+ if (invoice.credited_invoice_id) {
+ return {
+ ok: false,
+ code: 'INVOICE_PAID_NOT_PAYABLE',
+ details: { reason: 'credit_note' },
+ }
+ }
+
const now = new Date().toISOString()
// Drive the JE shape from the invoice's actual booking state, not from
diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts
index 07d00178..a9c2b921 100644
--- a/lib/pending-operations/__tests__/executors.test.ts
+++ b/lib/pending-operations/__tests__/executors.test.ts
@@ -143,6 +143,38 @@ describe('commitPendingOperation: unlock_period', () => {
})
})
+describe('commitPendingOperation: credit-note issuance guard', () => {
+ it('rejects mark_invoice_sent before the ordinary invoice executor can book it', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
+ enqueue({
+ data: makeInvoice({
+ id: 'credit-1',
+ status: 'draft',
+ credited_invoice_id: 'invoice-1',
+ }),
+ error: null,
+ })
+ enqueue({ data: null, error: null }) // dispatcher's rejected update
+
+ const op = makePendingOp({
+ operation_type: 'mark_invoice_sent',
+ params: { invoice_id: 'credit-1' },
+ })
+
+ const result = await commitPendingOperation(
+ supabase as never,
+ 'user-1',
+ 'company-1',
+ op,
+ )
+
+ expect(result.status).toBe('rejected')
+ expect(result.http_status).toBe(409)
+ expect(result.error).toContain('Credit notes must be issued')
+ })
+})
+
// ─── post_annual_depreciation ───────────────────────────────────────
describe('commitPendingOperation: post_annual_depreciation', () => {
diff --git a/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts b/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts
index b71f453c..7f589b97 100644
--- a/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts
+++ b/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts
@@ -253,6 +253,49 @@ describe('commitPendingOperation: link_transaction_journal_entry', () => {
})
})
+ it('rejects a credit note before linking the transaction', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
+ enqueue({
+ data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000 }),
+ error: null,
+ })
+ enqueue({
+ data: {
+ id: JE_UUID,
+ status: 'posted',
+ voucher_series: 'A',
+ voucher_number: 1,
+ entry_date: '2026-05-15',
+ },
+ error: null,
+ })
+ enqueue({
+ data: makeInvoice({
+ id: INV_UUID,
+ status: 'sent',
+ total: -1000,
+ remaining_amount: -1000,
+ credited_invoice_id: 'original-invoice-1',
+ }),
+ error: null,
+ })
+ enqueue({ data: null, error: null }) // dispatcher's reject update
+
+ const op = makePendingOp({
+ params: {
+ transaction_id: TX_UUID,
+ journal_entry_id: JE_UUID,
+ invoice_id: INV_UUID,
+ },
+ })
+ const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
+
+ expect(result.status).toBe('failed')
+ expect(result.http_status).toBe(400)
+ expect(result.error).toBe('Credit notes cannot be recorded as paid.')
+ })
+
it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
diff --git a/lib/pending-operations/__tests__/mark-invoice-paid.test.ts b/lib/pending-operations/__tests__/mark-invoice-paid.test.ts
index a0485358..f6a6bf45 100644
--- a/lib/pending-operations/__tests__/mark-invoice-paid.test.ts
+++ b/lib/pending-operations/__tests__/mark-invoice-paid.test.ts
@@ -67,6 +67,35 @@ beforeEach(() => {
})
describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () => {
+ it('rejects credit notes before creating a payment journal entry', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
+ enqueue({
+ data: {
+ id: 'credit-1',
+ invoice_number: 'KR-F-2026001',
+ status: 'sent',
+ total: -525,
+ remaining_amount: -525,
+ paid_amount: null,
+ credited_invoice_id: 'inv-1',
+ document_type: 'invoice',
+ journal_entry_id: 'je-credit',
+ customer: { name: 'Test AB' },
+ },
+ error: null,
+ }) // invoice fetch
+ enqueue({ data: null, error: null }) // dispatcher pending_operations update
+
+ const op = makePendingOp({ params: { invoice_id: 'credit-1', payment_date: '2026-03-30' } })
+ const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
+
+ expect(result.status).toBe('rejected')
+ expect(result.http_status).toBe(409)
+ expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
+ expect(mockCreateCashEntry).not.toHaveBeenCalled()
+ })
+
it('zeroes remaining_amount and emits invoice.paid on full payment (issue #825)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
diff --git a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts
index 8e015bc6..1c4412a1 100644
--- a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts
+++ b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts
@@ -59,6 +59,42 @@ beforeEach(() => {
})
describe('commitPendingOperation: match_transaction_invoice settlement account resolution', () => {
+ it('rejects a credit note before creating a payment journal entry', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
+ enqueue({
+ data: {
+ id: 'tx-1',
+ company_id: 'company-1',
+ amount: 12500,
+ currency: 'SEK',
+ date: '2026-05-12',
+ invoice_id: null,
+ journal_entry_id: null,
+ },
+ error: null,
+ }) // transaction fetch
+ enqueue({
+ data: {
+ id: 'credit-1',
+ invoice_number: 'KR-F-2026001',
+ status: 'sent',
+ total: -12500,
+ credited_invoice_id: 'inv-1',
+ },
+ error: null,
+ }) // invoice fetch
+ enqueue({ data: null, error: null }) // dispatcher pending_operations update
+
+ const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'credit-1' } })
+ const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
+
+ expect(result.status).toBe('rejected')
+ expect(result.http_status).toBe(409)
+ expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
+ expect(mockCreateCashEntry).not.toHaveBeenCalled()
+ })
+
it('credits the payment JE to the transaction\'s own linked cash account, not a hardcoded 1930', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index ebd45d68..cdf5f921 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -905,6 +905,9 @@ async function commitMarkInvoicePaid(
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
+ if (invoice.credited_invoice_id) {
+ return { error: 'Kreditfakturor kan inte markeras som betalda.', status: 409 }
+ }
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
}
@@ -1143,6 +1146,12 @@ async function commitSendInvoice(
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
+ if (invoice.credited_invoice_id) {
+ return {
+ error: 'Credit notes must be issued through the invoice send flow',
+ status: 409,
+ }
+ }
// partially_paid/credited imply the invoice was already issued too: the
// status flip below would regress them to 'sent' (PR #666 review, ASVS V2.3).
if (['sent', 'paid', 'overdue', 'partially_paid', 'credited'].includes(invoice.status)) {
@@ -1312,6 +1321,12 @@ async function commitMarkInvoiceSent(
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
+ if (invoice.credited_invoice_id) {
+ return {
+ error: 'Credit notes must be issued through the invoice send flow',
+ status: 409,
+ }
+ }
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
try {
@@ -1374,6 +1389,9 @@ async function commitMatchTransactionInvoice(
.single()
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
+ if (invoice.credited_invoice_id) {
+ return { error: 'Kreditfakturor kan inte registreras som betalda.', status: 409 }
+ }
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
return { error: 'Invoice is not in a matchable state', status: 409 }
}
diff --git a/lib/transactions/link-journal-entry.ts b/lib/transactions/link-journal-entry.ts
index 8cc1f325..f0d56dc8 100644
--- a/lib/transactions/link-journal-entry.ts
+++ b/lib/transactions/link-journal-entry.ts
@@ -34,6 +34,7 @@ export type LinkTransactionJournalEntryErrorCode =
| 'LINK_TX_JE_NOT_POSTED'
| 'LINK_TX_INVOICE_NOT_FOUND'
| 'LINK_TX_INVOICE_NOT_OPEN'
+ | 'LINK_TX_INVOICE_CREDIT_NOTE'
| 'LINK_TX_INVOICE_CURRENCY_MISMATCH'
| 'LINK_TX_INVOICE_RACE'
| 'MATCH_INVOICE_RECORD_PAYMENT_FAILED'
@@ -186,6 +187,7 @@ export async function linkTransactionToJournalEntry(
| 'exchange_rate'
| 'paid_at'
| 'invoice_number'
+ | 'credited_invoice_id'
> & { customer?: { name?: string } | null }
let invoice: FetchedInvoice | null = null
let newPaidAmount = 0
@@ -200,7 +202,7 @@ export async function linkTransactionToJournalEntry(
const { data: invoiceRow, error: fetchInvError } = await supabase
.from('invoices')
.select(
- 'id, status, total, paid_amount, remaining_amount, currency, exchange_rate, paid_at, invoice_number, customer:customers(name)'
+ 'id, status, total, paid_amount, remaining_amount, currency, exchange_rate, paid_at, invoice_number, credited_invoice_id, customer:customers(name)'
)
.eq('id', invoiceId)
.eq('company_id', companyId)
@@ -210,6 +212,10 @@ export async function linkTransactionToJournalEntry(
return { ok: false, code: 'LINK_TX_INVOICE_NOT_FOUND' }
}
+ if (invoiceRow.credited_invoice_id) {
+ return { ok: false, code: 'LINK_TX_INVOICE_CREDIT_NOTE' }
+ }
+
if (
invoiceRow.status !== 'sent' &&
invoiceRow.status !== 'overdue' &&
diff --git a/messages/en.json b/messages/en.json
index 1dfc9381..86981f19 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -828,15 +828,16 @@
"edit_dialog_title": "Edit customer",
"viewer_disabled_tooltip": "You only have viewer access in this company",
"section_contact": "Contact details",
- "section_business": "Business details",
+ "section_business": "Customer details",
"section_summary": "Overview",
"section_notes": "Notes",
"section_invoices": "Invoices",
"no_contact_info": "No contact details",
- "no_business_info": "No business details",
+ "no_business_info": "No additional customer details",
"no_invoices": "No invoices linked to this customer",
"label_customer_number": "Customer number:",
"label_org_number": "Org. no.:",
+ "label_personal_number": "Personal no.:",
"label_vat": "VAT:",
"label_payment_terms": "Payment terms:",
"payment_terms_value": "{days} days",
@@ -1531,7 +1532,12 @@
"default_days_label": "Payment terms (days)",
"default_notes_label": "Default invoice text",
"default_notes_placeholder": "E.g. payment terms, delivery info...",
- "default_notes_help": "Suggested automatically for new invoices."
+ "default_notes_help": "Suggested automatically for new invoices.",
+ "reminder_days_heading": "Automatic reminders",
+ "reminder_days_help": "Choose how many days after the due date each reminder is sent. The days must be in ascending order.",
+ "reminder_days_level_1": "First reminder (days)",
+ "reminder_days_level_2": "Second reminder (days)",
+ "reminder_days_level_3": "Final reminder (days)"
},
"settings_pdf_print": {
"coming_soon": "Coming soon",
@@ -2498,9 +2504,15 @@
"title_invoice": "New invoice",
"title_proforma": "New proforma invoice",
"title_delivery_note": "New delivery note",
+ "title_copy": "Copy invoice",
"subtitle_invoice": "Create a new invoice",
"subtitle_proforma": "Create a proforma invoice (no bookkeeping)",
"subtitle_delivery_note": "Create a delivery note (without prices)",
+ "subtitle_copy": "Create a new invoice from reused content",
+ "copy_notice": "The content was copied from {number}. A new draft is created with a new date and invoice number. Check the customer, references, and ROT/RUT details before continuing.",
+ "copy_load_failed_title": "Could not copy the invoice",
+ "copy_load_failed_description": "The invoice does not exist or cannot be used as a template.",
+ "close": "Close",
"bank_missing_warning": "Payment details missing: you need to add them before creating an invoice.",
"bank_add_now": "Add now",
"customer_card_title": "Customer",
@@ -2710,9 +2722,13 @@
"sent_at_suffix": " • Sent {date}",
"convert_to_invoice": "Convert to invoice",
"send_via_email": "Send via email",
+ "send_via_email_and_book": "Send via email and post",
"mark_sent_manually": "Sent manually",
+ "mark_sent_and_book": "Mark as sent and post",
+ "complete_credit_bookkeeping": "Complete bookkeeping",
"mark_as_sent": "Mark as sent",
"mark_as_paid": "Mark as paid",
+ "copy_invoice": "Copy invoice",
"download_pdf": "Download PDF",
"viewer_disabled_tooltip": "You only have read-only access to this company",
"customer_card_title": "Customer",
@@ -2754,7 +2770,7 @@
"payment_status_view_voucher_unlinked": "No voucher linked",
"payment_status_empty": "No payments recorded yet.",
"reminders_card_title": "Reminders",
- "reminders_description": "Automatic reminders are sent at 15, 30 and 45 days overdue",
+ "reminders_description": "Automatic reminders are sent when the invoice is {day1}, {day2} and {day3} days overdue",
"reminders_empty": "No reminders have been sent yet.",
"reminder_level_label": "Level {level}",
"reminder_level_1": "Friendly reminder",
@@ -2765,6 +2781,8 @@
"reminder_objection": "Customer has objections",
"credited_card_title": "Credited",
"see_credit_note": "See credit note {number}",
+ "credit_draft_card_title": "Credit note waiting to be sent",
+ "open_credit_draft": "Open draft {number}",
"credit_note_card_title": "Credit note",
"credit_note_description": "This credit note credits",
"see_original_invoice": "See invoice {number}",
@@ -2774,6 +2792,7 @@
"actions_card_title": "Actions",
"cancel_action": "Cancel",
"delete_draft": "Cancel draft",
+ "remove_credit_draft": "Delete credit draft",
"create_credit_note": "Create credit note",
"no_customer_email_warning": "The customer has no email address. Add an email to be able to send the invoice digitally.",
"send_manual_hint_with_email": "Use if you've already sent the invoice another way (post, another system)",
@@ -2801,6 +2820,9 @@
"remove_dialog_title": "Delete draft?",
"remove_dialog_desc": "The draft and its rows are permanently deleted. Since no invoice number was assigned, the number series is unaffected. This cannot be undone.",
"remove_dialog_confirm": "Delete",
+ "remove_credit_dialog_title": "Delete credit draft?",
+ "remove_credit_dialog_desc": "The credit note has not been issued or posted. The draft and its lines are permanently deleted and can be created again.",
+ "remove_credit_dialog_confirm": "Delete draft",
"removed_toast_title": "Draft deleted",
"removed_toast_description": "The draft has been removed.",
"status_update_toast_title": "Updated",
@@ -2830,8 +2852,8 @@
"back": "Back",
"title": "Create credit note",
"subtitle": "Credits invoice {number}",
- "warning_title": "Irreversible action",
- "warning_description": "A credit note fully cancels the original invoice. All amounts become negative, a bookkeeping verifikat is created, and the original invoice is marked as credited. This action cannot be undone.",
+ "warning_title": "The credit note is created as a draft",
+ "warning_description": "All amounts become negative. The original invoice is marked as credited only when the credit note is sent or marked as sent. Bookkeeping is then handled according to the company's selected accounting method.",
"original_card_title": "Original invoice",
"invoice_number_label": "Invoice number:",
"date_label": "Date:",
@@ -2867,7 +2889,7 @@
"already_credited_title": "Already credited",
"already_credited_description": "This invoice has already been credited",
"created_toast_title": "Credit note created",
- "created_toast_description": "Credit note {number} has been created",
+ "created_toast_description": "Credit note {number} was created as a draft.",
"create_failed_title": "Could not create credit note",
"create_failed_fallback": "Failed to create credit note",
"try_again": "Try again."
@@ -2956,26 +2978,54 @@
"invoice_send_dialog": {
"title_email": "Send invoice",
"title_manual": "Mark as sent",
+ "title_credit_email": "Send credit note",
+ "title_credit_manual": "Mark credit note as sent",
+ "title_credit_repair": "Complete credit-note bookkeeping",
"title_suffix": ": {number}",
"description_sek_suffix": " ({amount} SEK)",
"description_to_email": " to {email}",
"load_failed_title": "Could not load settings",
"no_active_company": "No active company",
"company_settings_failed": "Could not load company settings",
+ "fiscal_period_failed": "Could not load the fiscal period",
+ "original_invoice_failed": "Could not load the original invoice",
"journal_preview_intro": "The following bookkeeping verifikat is created automatically:",
"voucher_description": "Sales invoice{numberSpace}{customerSuffix}",
+ "credit_voucher_description": "Credit note{numberSpace}{customerSuffix}",
"explain_cash": "Cash method: bookkeeping happens on payment, not on invoicing.",
+ "explain_credit_cash": "Cash method: the original invoice is unpaid, so no verifikat is created when the credit note is issued.",
"explain_email": "The invoice is sent to {email}.",
"explain_manual": "The invoice is marked as sent.",
"cancel": "Cancel",
+ "later": "Do later",
"send_invoice": "Send invoice",
+ "send_invoice_and_book": "Send and post",
"mark_as_sent": "Mark as sent",
+ "mark_as_sent_and_book": "Mark as sent and post",
+ "send_credit_note": "Send credit note",
+ "send_credit_note_and_book": "Send and post credit note",
+ "mark_credit_note_sent": "Mark as sent",
+ "mark_credit_note_sent_and_book": "Mark as sent and post",
+ "complete_credit_bookkeeping": "Complete bookkeeping",
"send_failed_title": "Could not send invoice",
+ "credit_send_failed_title": "Could not issue the credit note",
"send_failed_fallback": "Could not send the invoice",
"send_success_title": "Invoice sent",
+ "send_book_success_title": "Sent and posted",
+ "credit_send_success_title": "Credit note sent",
+ "credit_send_book_success_title": "Credit note sent and posted",
+ "credit_send_success": "The credit note was sent to {email} and issued.",
"send_success_default": "The invoice was sent to {email}",
+ "partial_success": "{message}. A follow-up step failed. Check the invoice and the bookkeeping.",
"mark_success_title": "Invoice marked as sent",
+ "mark_book_success_title": "Sent and posted",
+ "credit_mark_success_title": "Credit note issued",
+ "credit_mark_book_success_title": "Credit note sent and posted",
+ "credit_repair_success_title": "Bookkeeping completed",
+ "credit_mark_success_voucher_created": "The credit note was marked as sent and the verifikat was created.",
+ "credit_mark_success_no_voucher": "The credit note was marked as sent. The cash method requires no verifikat for an unpaid original invoice.",
"mark_success_voucher_created": "The bookkeeping verifikat has been created.",
+ "mark_partial_success": "The invoice was marked as sent, but a follow-up step failed. Check the invoice and the bookkeeping.",
"try_again": "Try again."
},
"invoice_payment_dialog": {
@@ -3060,6 +3110,14 @@
"due_date_label": "Due date",
"payment_reference_label": "OCR / Payment reference",
"payment_reference_placeholder": "OCR-nummer",
+ "document_label": "Invoice document",
+ "document_help": "Attach the supplier's PDF or an image. The document is stored with the invoice and automatically attached to the verifikation.",
+ "document_upload_in_progress_title": "The document is still uploading",
+ "document_upload_in_progress_description": "Wait for the upload to finish before registering the invoice.",
+ "document_upload_failed_title": "The document could not be uploaded",
+ "document_upload_failed_description": "Remove the file and try uploading it again.",
+ "document_link_warning_title": "The invoice was registered, but the document is missing from the verifikation",
+ "document_link_warning_description": "Open the verifikation and attach the document there.",
"add_row": "Add row",
"currency_label": "Currency",
"exchange_rate_label": "Exchange rate",
@@ -3267,6 +3325,9 @@
"registration_voucher": "Registration verifikat",
"no_registration_voucher": "No registration verifikat (cash method)",
"payment_voucher": "Payment verifikat",
+ "document_title": "Invoice document",
+ "document_attached": "The supplier invoice is attached and archived.",
+ "view_document": "View document",
"notes_title": "Notes",
"pay_dialog_title": "Mark as paid",
"tab_new_payment": "New payment",
@@ -3474,11 +3535,27 @@
"corrupt_replace_cta": "Upload new version",
"integrity_unknown": "File integrity could not be verified: try again later if the preview does not appear."
},
+ "document_upload": {
+ "session_expired": "Your session has expired. Reload the page and sign in again.",
+ "server_status": "The server responded with {status}.",
+ "failed_status": "Upload failed ({status})",
+ "network_error": "Upload failed: network error",
+ "unsupported_type": "This file type is not supported",
+ "too_large": "The file is too large (max 10 MB)",
+ "compact_prompt": "Drag and drop or click",
+ "prompt": "Drag and drop files here",
+ "format_hint": "PDF or image (max 10 MB)",
+ "uploaded": "Uploaded",
+ "error": "Error",
+ "remove_file": "Remove file",
+ "uploading": "Uploading..."
+ },
"journal_attachments": {
"loading": "Loading documents...",
"title": "Documents",
"add": "Add document",
"empty": "No documents attached.",
+ "via_supplier_invoice": "Via supplier invoice",
"download": "Download",
"remove": "Remove",
"replace": "Replace with new version",
@@ -3924,7 +4001,7 @@
"ext_enable_banking_long_description": "Connect your bank account directly and sync transactions automatically via secure PSD2 bank integration. Supports most Swedish banks.",
"ext_email_name": "Email (Resend)",
"ext_email_description": "Send invoices and reminders via email",
- "ext_email_long_description": "Enables email features: send invoices to customers, automatic payment reminders (15/30/45 days), and email notifications. Requires a Resend account with a verified domain.",
+ "ext_email_long_description": "Enables email features: send invoices to customers, automatic payment reminders on your chosen schedule, and email notifications. Requires a Resend account with a verified domain.",
"ext_arcim_migration_name": "System migration",
"ext_arcim_migration_description": "Migrate bookkeeping from Fortnox, Visma, Bokio, Björn Lundén or Briox",
"ext_arcim_migration_long_description": "Move all bookkeeping data from your old system to accounted. Imports chart of accounts, vouchers, customers, suppliers and open invoices automatically via a secure API integration directly with the provider.",
@@ -4217,6 +4294,7 @@
"back": "Back to articles",
"edit": "Edit",
"deactivate": "Deactivate",
+ "delete": "Delete",
"edit_dialog_title": "Edit article",
"viewer_disabled_tooltip": "You only have viewer access in this company",
"type_vara": "Goods",
@@ -4247,6 +4325,11 @@
"deactivate_confirm_label": "Deactivate",
"deactivated_title": "Article deactivated",
"deactivate_failed_title": "Could not deactivate article",
+ "delete_confirm_title": "Delete {name}",
+ "delete_confirm_description": "The article and its article number are permanently deleted. This is only possible if the article has never been used on an invoice.",
+ "delete_confirm_label": "Delete",
+ "deleted_title": "Article deleted",
+ "delete_failed_title": "Could not delete article",
"activate_and_save": "Activate and save"
},
"form_article": {
diff --git a/messages/sv.json b/messages/sv.json
index 63d0146c..faa1f70e 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -828,15 +828,16 @@
"edit_dialog_title": "Redigera kund",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
"section_contact": "Kontaktuppgifter",
- "section_business": "Företagsuppgifter",
+ "section_business": "Kunduppgifter",
"section_summary": "Översikt",
"section_notes": "Anteckningar",
"section_invoices": "Fakturor",
"no_contact_info": "Inga kontaktuppgifter",
- "no_business_info": "Inga företagsuppgifter",
+ "no_business_info": "Inga ytterligare kunduppgifter",
"no_invoices": "Inga fakturor kopplade till denna kund",
"label_customer_number": "Kundnummer:",
"label_org_number": "Org.nr:",
+ "label_personal_number": "Persnr:",
"label_vat": "VAT:",
"label_payment_terms": "Betalningsvillkor:",
"payment_terms_value": "{days} dagar",
@@ -1531,7 +1532,12 @@
"default_days_label": "Betalningsvillkor (dagar)",
"default_notes_label": "Standardtext på fakturor",
"default_notes_placeholder": "T.ex. betalningsvillkor, leveransinfo...",
- "default_notes_help": "Föreslås automatiskt vid ny faktura."
+ "default_notes_help": "Föreslås automatiskt vid ny faktura.",
+ "reminder_days_heading": "Automatiska påminnelser",
+ "reminder_days_help": "Ange hur många dagar efter förfallodatum varje påminnelse skickas. Dagarna måste vara i stigande ordning.",
+ "reminder_days_level_1": "Första påminnelsen (dagar)",
+ "reminder_days_level_2": "Andra påminnelsen (dagar)",
+ "reminder_days_level_3": "Slutlig påminnelse (dagar)"
},
"settings_pdf_print": {
"coming_soon": "Kommer snart",
@@ -2498,9 +2504,15 @@
"title_invoice": "Ny faktura",
"title_proforma": "Ny proformafaktura",
"title_delivery_note": "Ny följesedel",
+ "title_copy": "Kopiera faktura",
"subtitle_invoice": "Skapa en ny faktura",
"subtitle_proforma": "Skapa en proformafaktura (ingen bokföring)",
"subtitle_delivery_note": "Skapa en följesedel (utan priser)",
+ "subtitle_copy": "Skapa en ny faktura med återanvänt innehåll",
+ "copy_notice": "Innehållet har kopierats från {number}. Ett nytt utkast skapas med nytt datum och nytt fakturanummer. Kontrollera kund, referenser och ROT/RUT-uppgifter innan du fortsätter.",
+ "copy_load_failed_title": "Kunde inte kopiera fakturan",
+ "copy_load_failed_description": "Fakturan finns inte eller kan inte användas som mall.",
+ "close": "Stäng",
"bank_missing_warning": "Betalningsuppgifter saknas: du behöver lägga till dem innan du skapar en faktura.",
"bank_add_now": "Lägg till nu",
"customer_card_title": "Kund",
@@ -2710,9 +2722,13 @@
"sent_at_suffix": " • Skickad {date}",
"convert_to_invoice": "Konvertera till faktura",
"send_via_email": "Skicka via e-post",
+ "send_via_email_and_book": "Skicka via e-post och bokför",
"mark_sent_manually": "Skickad manuellt",
+ "mark_sent_and_book": "Markera som skickad och bokför",
+ "complete_credit_bookkeeping": "Slutför bokföring",
"mark_as_sent": "Markera som skickad",
"mark_as_paid": "Markera som betald",
+ "copy_invoice": "Kopiera faktura",
"download_pdf": "Ladda ner PDF",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
"customer_card_title": "Kund",
@@ -2754,7 +2770,7 @@
"payment_status_view_voucher_unlinked": "Saknar verifikat",
"payment_status_empty": "Inga registrerade betalningar ännu.",
"reminders_card_title": "Påminnelser",
- "reminders_description": "Automatiska påminnelser skickas vid 15, 30 och 45 dagars förfallen betalning",
+ "reminders_description": "Automatiska påminnelser skickas när fakturan varit förfallen i {day1}, {day2} och {day3} dagar",
"reminders_empty": "Inga påminnelser har skickats ännu.",
"reminder_level_label": "Nivå {level}",
"reminder_level_1": "Vänlig påminnelse",
@@ -2765,6 +2781,8 @@
"reminder_objection": "Kunden har invändningar",
"credited_card_title": "Krediterad",
"see_credit_note": "Se kreditfaktura {number}",
+ "credit_draft_card_title": "Kreditfaktura väntar på att skickas",
+ "open_credit_draft": "Öppna utkast {number}",
"credit_note_card_title": "Kreditfaktura",
"credit_note_description": "Denna kreditfaktura krediterar",
"see_original_invoice": "Se faktura {number}",
@@ -2774,6 +2792,7 @@
"actions_card_title": "Åtgärder",
"cancel_action": "Makulera",
"delete_draft": "Makulera utkast",
+ "remove_credit_draft": "Ta bort kreditutkast",
"create_credit_note": "Skapa kreditfaktura",
"no_customer_email_warning": "Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.",
"send_manual_hint_with_email": "Använd om du redan skickat fakturan på annat sätt (post, annat system)",
@@ -2801,6 +2820,9 @@
"remove_dialog_title": "Ta bort utkast?",
"remove_dialog_desc": "Utkastet och dess rader tas bort permanent. Eftersom inget fakturanummer tilldelats påverkas inte nummerserien. Detta kan inte ångras.",
"remove_dialog_confirm": "Ta bort",
+ "remove_credit_dialog_title": "Ta bort kreditutkast?",
+ "remove_credit_dialog_desc": "Kreditfakturan har inte utfärdats eller bokförts. Utkastet och dess rader tas bort permanent och kan skapas på nytt.",
+ "remove_credit_dialog_confirm": "Ta bort utkast",
"removed_toast_title": "Utkast borttaget",
"removed_toast_description": "Utkastet har tagits bort.",
"status_update_toast_title": "Uppdaterad",
@@ -2830,8 +2852,8 @@
"back": "Tillbaka",
"title": "Skapa kreditfaktura",
"subtitle": "Krediterar faktura {number}",
- "warning_title": "Oåterkallelig åtgärd",
- "warning_description": "En kreditfaktura makulerar den ursprungliga fakturan helt. Alla belopp blir negativa, en bokföringsverifikation skapas, och den ursprungliga fakturan markeras som krediterad. Denna åtgärd kan inte ångras.",
+ "warning_title": "Kreditfakturan skapas som utkast",
+ "warning_description": "Alla belopp blir negativa. Originalfakturan markeras som krediterad först när kreditfakturan skickas eller markeras som skickad. Bokföringen hanteras då enligt företagets valda bokföringsmetod.",
"original_card_title": "Ursprunglig faktura",
"invoice_number_label": "Fakturanummer:",
"date_label": "Datum:",
@@ -2867,7 +2889,7 @@
"already_credited_title": "Redan krediterad",
"already_credited_description": "Denna faktura har redan krediterats",
"created_toast_title": "Kreditfaktura skapad",
- "created_toast_description": "Kreditfaktura {number} har skapats",
+ "created_toast_description": "Kreditfaktura {number} har skapats som utkast.",
"create_failed_title": "Kunde inte skapa kreditfaktura",
"create_failed_fallback": "Kunde inte skapa kreditfaktura",
"try_again": "Försök igen."
@@ -2956,26 +2978,54 @@
"invoice_send_dialog": {
"title_email": "Skicka faktura",
"title_manual": "Markera som skickad",
+ "title_credit_email": "Skicka kreditfaktura",
+ "title_credit_manual": "Markera kreditfaktura som skickad",
+ "title_credit_repair": "Slutför kreditfakturans bokföring",
"title_suffix": ": {number}",
"description_sek_suffix": " ({amount} SEK)",
"description_to_email": " till {email}",
"load_failed_title": "Kunde inte ladda inställningar",
"no_active_company": "Inget aktivt företag",
"company_settings_failed": "Kunde inte ladda företagsinställningar",
+ "fiscal_period_failed": "Kunde inte ladda räkenskapsperioden",
+ "original_invoice_failed": "Kunde inte ladda originalfakturan",
"journal_preview_intro": "Följande bokföringsverifikation skapas automatiskt:",
"voucher_description": "Försäljning faktura{numberSpace}{customerSuffix}",
+ "credit_voucher_description": "Kreditfaktura{numberSpace}{customerSuffix}",
"explain_cash": "Kontantmetoden: bokföring sker vid betalning, inte vid fakturering.",
+ "explain_credit_cash": "Kontantmetoden: originalfakturan är obetald, så ingen verifikation skapas när kreditfakturan utfärdas.",
"explain_email": "Fakturan skickas till {email}.",
"explain_manual": "Fakturan markeras som skickad.",
"cancel": "Avbryt",
+ "later": "Gör senare",
"send_invoice": "Skicka faktura",
+ "send_invoice_and_book": "Skicka och bokför",
"mark_as_sent": "Markera som skickad",
+ "mark_as_sent_and_book": "Markera som skickad och bokför",
+ "send_credit_note": "Skicka kreditfaktura",
+ "send_credit_note_and_book": "Skicka och bokför kreditfaktura",
+ "mark_credit_note_sent": "Markera som skickad",
+ "mark_credit_note_sent_and_book": "Markera som skickad och bokför",
+ "complete_credit_bookkeeping": "Slutför bokföring",
"send_failed_title": "Kunde inte skicka faktura",
+ "credit_send_failed_title": "Kunde inte utfärda kreditfakturan",
"send_failed_fallback": "Kunde inte skicka fakturan",
"send_success_title": "Faktura skickad",
+ "send_book_success_title": "Skickad och bokförd",
+ "credit_send_success_title": "Kreditfaktura skickad",
+ "credit_send_book_success_title": "Kreditfaktura skickad och bokförd",
+ "credit_send_success": "Kreditfakturan har skickats till {email} och utfärdats.",
"send_success_default": "Fakturan har skickats till {email}",
+ "partial_success": "{message}. Ett efterföljande steg misslyckades. Kontrollera fakturan och bokföringen.",
"mark_success_title": "Faktura markerad som skickad",
+ "mark_book_success_title": "Skickad och bokförd",
+ "credit_mark_success_title": "Kreditfaktura utfärdad",
+ "credit_mark_book_success_title": "Kreditfaktura skickad och bokförd",
+ "credit_repair_success_title": "Bokföringen är slutförd",
+ "credit_mark_success_voucher_created": "Kreditfakturan är markerad som skickad och verifikationen har skapats.",
+ "credit_mark_success_no_voucher": "Kreditfakturan är markerad som skickad. Kontantmetoden kräver ingen verifikation för en obetald originalfaktura.",
"mark_success_voucher_created": "Bokföringsverifikationen har skapats.",
+ "mark_partial_success": "Fakturan markerades som skickad, men ett efterföljande steg misslyckades. Kontrollera fakturan och bokföringen.",
"try_again": "Försök igen."
},
"invoice_payment_dialog": {
@@ -3060,6 +3110,14 @@
"due_date_label": "Förfallodatum",
"payment_reference_label": "OCR / Betalningsreferens",
"payment_reference_placeholder": "OCR-nummer",
+ "document_label": "Fakturaunderlag",
+ "document_help": "Bifoga leverantörens PDF eller en bild. Underlaget sparas med fakturan och kopplas automatiskt till verifikationen.",
+ "document_upload_in_progress_title": "Underlaget laddas fortfarande upp",
+ "document_upload_in_progress_description": "Vänta tills uppladdningen är klar innan du registrerar fakturan.",
+ "document_upload_failed_title": "Underlaget kunde inte laddas upp",
+ "document_upload_failed_description": "Ta bort filen och försök ladda upp den igen.",
+ "document_link_warning_title": "Fakturan registrerades, men underlaget saknas på verifikationen",
+ "document_link_warning_description": "Öppna verifikationen och bifoga underlaget där.",
"add_row": "Lägg till rad",
"currency_label": "Valuta",
"exchange_rate_label": "Växelkurs",
@@ -3267,6 +3325,9 @@
"registration_voucher": "Registreringsverifikation",
"no_registration_voucher": "Ingen registreringsverifikation (kontantmetoden)",
"payment_voucher": "Betalningsverifikation",
+ "document_title": "Fakturaunderlag",
+ "document_attached": "Leverantörens faktura är bifogad och arkiverad.",
+ "view_document": "Visa underlag",
"notes_title": "Anteckningar",
"pay_dialog_title": "Markera som betald",
"tab_new_payment": "Ny betalning",
@@ -3474,11 +3535,27 @@
"corrupt_replace_cta": "Ladda upp ny version",
"integrity_unknown": "Filens integritet kunde inte verifieras: försök igen senare om förhandsgranskningen inte visas."
},
+ "document_upload": {
+ "session_expired": "Din session har gått ut. Ladda om sidan och logga in igen.",
+ "server_status": "Servern svarade {status}.",
+ "failed_status": "Uppladdningen misslyckades ({status})",
+ "network_error": "Uppladdningen misslyckades: nätverksfel",
+ "unsupported_type": "Filtypen stöds inte",
+ "too_large": "Filen är för stor (max 10 MB)",
+ "compact_prompt": "Dra och släpp eller klicka",
+ "prompt": "Dra och släpp filer här",
+ "format_hint": "PDF eller bild (max 10 MB)",
+ "uploaded": "Uppladdad",
+ "error": "Fel",
+ "remove_file": "Ta bort fil",
+ "uploading": "Laddar upp..."
+ },
"journal_attachments": {
"loading": "Laddar underlag...",
"title": "Underlag",
"add": "Lägg till underlag",
"empty": "Inga underlag bifogade.",
+ "via_supplier_invoice": "Via leverantörsfaktura",
"download": "Ladda ner",
"remove": "Ta bort",
"replace": "Ersätt med ny version",
@@ -3924,7 +4001,7 @@
"ext_enable_banking_long_description": "Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.",
"ext_email_name": "E-post (Resend)",
"ext_email_description": "Skicka fakturor och påminnelser via e-post",
- "ext_email_long_description": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
+ "ext_email_long_description": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
"ext_arcim_migration_name": "Systemmigration",
"ext_arcim_migration_description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
"ext_arcim_migration_long_description": "Flytta all bokföringsdata från ditt gamla system till accounted. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration direkt med leverantören.",
@@ -4217,6 +4294,7 @@
"back": "Tillbaka till artiklar",
"edit": "Redigera",
"deactivate": "Inaktivera",
+ "delete": "Ta bort",
"edit_dialog_title": "Redigera artikel",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
"type_vara": "Vara",
@@ -4247,6 +4325,11 @@
"deactivate_confirm_label": "Inaktivera",
"deactivated_title": "Artikel inaktiverad",
"deactivate_failed_title": "Kunde inte inaktivera artikel",
+ "delete_confirm_title": "Ta bort {name}",
+ "delete_confirm_description": "Artikeln och dess artikelnummer tas bort permanent. Det går bara om artikeln aldrig har använts på en faktura.",
+ "delete_confirm_label": "Ta bort",
+ "deleted_title": "Artikel borttagen",
+ "delete_failed_title": "Kunde inte ta bort artikel",
"activate_and_save": "Aktivera och spara"
},
"form_article": {
diff --git a/supabase/migrations/20260714121345_prevent_credit_note_paid_status.sql b/supabase/migrations/20260714121345_prevent_credit_note_paid_status.sql
new file mode 100644
index 00000000..15687dc2
--- /dev/null
+++ b/supabase/migrations/20260714121345_prevent_credit_note_paid_status.sql
@@ -0,0 +1,28 @@
+-- Credit notes are adjustments, not customer receivables. They must not enter
+-- the ordinary customer-payment lifecycle.
+--
+-- NOT VALID avoids scanning or rewriting existing rows during deployment. The
+-- constraint still applies to every new row and every future update, so all
+-- API, RPC, extension, and legacy write paths share the same invariant.
+DO $migration$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'invoices_credit_note_not_paid'
+ AND conrelid = 'public.invoices'::regclass
+ ) THEN
+ ALTER TABLE public.invoices
+ ADD CONSTRAINT invoices_credit_note_not_paid
+ CHECK (
+ credited_invoice_id IS NULL
+ OR status NOT IN ('paid', 'partially_paid')
+ ) NOT VALID;
+ END IF;
+END
+$migration$;
+
+COMMENT ON CONSTRAINT invoices_credit_note_not_paid ON public.invoices IS
+ 'Credit notes cannot use the ordinary customer-invoice payment states.';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260714122431_validate_credit_note_paid_status.sql b/supabase/migrations/20260714122431_validate_credit_note_paid_status.sql
new file mode 100644
index 00000000..cb05026a
--- /dev/null
+++ b/supabase/migrations/20260714122431_validate_credit_note_paid_status.sql
@@ -0,0 +1,6 @@
+-- Validate separately from ADD CONSTRAINT so the table scan does not run while
+-- the stronger ALTER TABLE lock from the first migration is still held.
+ALTER TABLE public.invoices
+ VALIDATE CONSTRAINT invoices_credit_note_not_paid;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260714133716_add_invoice_reminder_days.sql b/supabase/migrations/20260714133716_add_invoice_reminder_days.sql
new file mode 100644
index 00000000..a2ac42fd
--- /dev/null
+++ b/supabase/migrations/20260714133716_add_invoice_reminder_days.sql
@@ -0,0 +1,26 @@
+-- Per-company schedule for the three automatic customer invoice reminders.
+-- Defaults preserve the existing 15, 30, and 45 days overdue behavior.
+
+ALTER TABLE public.company_settings
+ ADD COLUMN IF NOT EXISTS reminder_days_level_1 SMALLINT NOT NULL DEFAULT 15,
+ ADD COLUMN IF NOT EXISTS reminder_days_level_2 SMALLINT NOT NULL DEFAULT 30,
+ ADD COLUMN IF NOT EXISTS reminder_days_level_3 SMALLINT NOT NULL DEFAULT 45;
+
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_reminder_days_check
+ CHECK (
+ reminder_days_level_1 BETWEEN 1 AND 365
+ AND reminder_days_level_2 BETWEEN 1 AND 365
+ AND reminder_days_level_3 BETWEEN 1 AND 365
+ AND reminder_days_level_1 < reminder_days_level_2
+ AND reminder_days_level_2 < reminder_days_level_3
+ ) NOT VALID;
+
+COMMENT ON COLUMN public.company_settings.reminder_days_level_1
+ IS 'Days overdue before the first automatic customer invoice reminder.';
+COMMENT ON COLUMN public.company_settings.reminder_days_level_2
+ IS 'Days overdue before the second automatic customer invoice reminder.';
+COMMENT ON COLUMN public.company_settings.reminder_days_level_3
+ IS 'Days overdue before the final automatic customer invoice reminder.';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260714133717_validate_invoice_reminder_days.sql b/supabase/migrations/20260714133717_validate_invoice_reminder_days.sql
new file mode 100644
index 00000000..63d35867
--- /dev/null
+++ b/supabase/migrations/20260714133717_validate_invoice_reminder_days.sql
@@ -0,0 +1,4 @@
+ALTER TABLE public.company_settings
+ VALIDATE CONSTRAINT company_settings_reminder_days_check;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260715120000_credit_note_creation_and_posting_guards.sql b/supabase/migrations/20260715120000_credit_note_creation_and_posting_guards.sql
new file mode 100644
index 00000000..9212b1b1
--- /dev/null
+++ b/supabase/migrations/20260715120000_credit_note_creation_and_posting_guards.sql
@@ -0,0 +1,20 @@
+-- Keep partially-created credit notes invisible and prevent duplicate credit
+-- relationships or posted vouchers under concurrent requests.
+
+ALTER TABLE public.invoices
+ ADD COLUMN IF NOT EXISTS creation_complete boolean NOT NULL DEFAULT true;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_invoices_company_credited_invoice
+ ON public.invoices (company_id, credited_invoice_id)
+ WHERE credited_invoice_id IS NOT NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_posted_credit_note_journal_source
+ ON public.journal_entries (company_id, source_id)
+ WHERE source_type = 'credit_note'
+ AND source_id IS NOT NULL
+ AND status = 'posted';
+
+COMMENT ON COLUMN public.invoices.creation_complete IS
+ 'False only while a credit-note parent and its items are being assembled. API reads must expose only complete credit notes.';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/helpers.ts b/tests/helpers.ts
index cf2a6022..a179ddf4 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -587,6 +587,9 @@ export function makeCompanySettings(
invoice_footer_text: null,
invoice_email_texts: null,
send_invoice_reminders: true,
+ reminder_days_level_1: 15,
+ reminder_days_level_2: 30,
+ reminder_days_level_3: 45,
reminder_fee_enabled: true,
reminder_fee_amount: 60,
reminder_interest_rate_override: null,
diff --git a/tests/pg/credit-note-creation-guards.pg.test.ts b/tests/pg/credit-note-creation-guards.pg.test.ts
new file mode 100644
index 00000000..05c3f1c2
--- /dev/null
+++ b/tests/pg/credit-note-creation-guards.pg.test.ts
@@ -0,0 +1,77 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { getPool } from './setup'
+import { seedCompany } from './fixtures'
+
+async function insertInvoice(input: {
+ userId: string
+ companyId: string
+ number: string
+ creditedInvoiceId?: string
+ creationComplete?: boolean
+}): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.invoices
+ (id, user_id, company_id, invoice_number, invoice_date, due_date,
+ credited_invoice_id, creation_complete)
+ VALUES ($1, $2, $3, $4, '2026-07-15', '2026-07-15', $5, $6)`,
+ [
+ id,
+ input.userId,
+ input.companyId,
+ input.number,
+ input.creditedInvoiceId ?? null,
+ input.creationComplete ?? true,
+ ],
+ )
+ return id
+}
+
+describe('credit note creation and posting guards', () => {
+ it('allows only one credit-note relationship per original and company', async () => {
+ const { userId, companyId } = await seedCompany()
+ const originalId = await insertInvoice({ userId, companyId, number: `F-${randomUUID()}` })
+
+ await insertInvoice({
+ userId,
+ companyId,
+ number: `KR-${randomUUID()}`,
+ creditedInvoiceId: originalId,
+ })
+
+ await expect(
+ insertInvoice({
+ userId,
+ companyId,
+ number: `KR-${randomUUID()}`,
+ creditedInvoiceId: originalId,
+ }),
+ ).rejects.toThrow(/duplicate|unique/i)
+ })
+
+ it('persists the completion marker and installs a unique posted-source guard', async () => {
+ const { userId, companyId } = await seedCompany()
+ const invoiceId = await insertInvoice({
+ userId,
+ companyId,
+ number: `KR-${randomUUID()}`,
+ creationComplete: false,
+ })
+
+ const invoice = await getPool().query<{ creation_complete: boolean }>(
+ `SELECT creation_complete FROM public.invoices WHERE id = $1`,
+ [invoiceId],
+ )
+ expect(invoice.rows[0].creation_complete).toBe(false)
+
+ const index = await getPool().query<{ indexdef: string }>(
+ `SELECT indexdef FROM pg_indexes
+ WHERE schemaname = 'public' AND indexname = 'uq_posted_credit_note_journal_source'`,
+ )
+ expect(index.rows).toHaveLength(1)
+ expect(index.rows[0].indexdef).toMatch(/UNIQUE/)
+ expect(index.rows[0].indexdef).toMatch(/source_type.*credit_note/)
+ expect(index.rows[0].indexdef).toMatch(/status.*posted/)
+ })
+})
diff --git a/types/index.ts b/types/index.ts
index afd5e4b2..b6393ee2 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -313,6 +313,9 @@ export interface CompanySettings {
// Automation
send_invoice_reminders: boolean
+ reminder_days_level_1: number
+ reminder_days_level_2: number
+ reminder_days_level_3: number
// Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
reminder_fee_enabled: boolean
@@ -1157,7 +1160,7 @@ export interface CreateCustomerInput {
country?: string
org_number?: string
vat_number?: string
- personal_number?: string
+ personal_number?: string | null
language?: 'sv' | 'en'
default_payment_terms?: number
notes?: string