Fix/supp ag fb (#1023)

* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
This commit is contained in:
Mattsson
2026-07-15 15:53:15 +02:00
committed by GitHub
parent a558c75678
commit 072aedeaf9
116 changed files with 5708 additions and 669 deletions
@@ -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.
+23
View File
@@ -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.
+35
View File
@@ -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: >-
+2
View File
@@ -94,3 +94,5 @@ scripts/reopen-bokslut.sql
.claude/plans/write-up-a-plan-streamed-fiddle.md
/ingaende-balanser-test.csv
.agents
.codex
+106
View File
@@ -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 <dir>` 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 <dir> # 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/<name>/`; `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] <decision>: <why>`. Check that file before re-litigating a past decision.
+13
View File
@@ -150,4 +150,17 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+34 -27
View File
@@ -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 (
<div className="space-y-8">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
<div>
<Link
href="/articles"
@@ -203,7 +206,7 @@ export default function ArticleDetailPage({
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
@@ -214,19 +217,23 @@ export default function ArticleDetailPage({
{canWrite ? <Edit2 className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
{t('edit')}
</Button>
{article.active && (
<Button
variant="outline"
size="sm"
onClick={handleDeactivate}
className="text-destructive hover:text-destructive"
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Archive className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
{t('deactivate')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleDelete}
className="min-h-10 text-destructive hover:text-destructive"
disabled={isDeleting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isDeleting ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : canWrite ? (
<Trash2 className="h-4 w-4 mr-1" />
) : (
<Lock className="h-4 w-4 mr-1" />
)}
{t('delete')}
</Button>
</div>
</div>
-1
View File
@@ -102,7 +102,6 @@ function ArticlesPageInner() {
.from('articles')
.select('*')
.eq('company_id', company.id)
.eq('active', true)
.order('name', { ascending: true })
if (error) {
+13 -3
View File
@@ -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({
</CardContent>
</Card>
{/* Business details */}
{/* Customer details */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('section_business')}</CardTitle>
@@ -287,12 +288,20 @@ export default function CustomerDetailPage({
{customer.customer_number}
</div>
)}
{customer.org_number && (
{customer.customer_type !== 'individual' && customer.org_number && (
<div className="text-sm">
<span className="text-muted-foreground">{t('label_org_number')} </span>
{customer.org_number}
</div>
)}
{customer.customer_type === 'individual' && (customer.personal_number || customer.org_number) && (
<div className="text-sm">
<span className="text-muted-foreground">{t('label_personal_number')} </span>
<span className="tabular-nums">
{maskCustomerPersonalNumber(customer.personal_number || customer.org_number)}
</span>
</div>
)}
{customer.vat_number && (
<div className="text-sm flex items-center gap-2">
<span className="text-muted-foreground">{t('label_vat')} </span>
@@ -306,7 +315,7 @@ export default function CustomerDetailPage({
<span className="text-muted-foreground">{t('label_payment_terms')} </span>
{t('payment_terms_value', { days: customer.default_payment_terms || 30 })}
</div>
{!customer.customer_number && !customer.org_number && !customer.vat_number && (
{!customer.customer_number && !customer.org_number && !customer.personal_number && !customer.vat_number && (
<p className="text-sm text-muted-foreground">{t('no_business_info')}</p>
)}
</CardContent>
@@ -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,
}}
+35 -3
View File
@@ -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<InvoiceWithRelations | null>(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 (
<div className="space-y-6 max-w-3xl mx-auto">
{createdCreditNote && (
<SendInvoiceDialog
open={showSendPrompt}
onOpenChange={handleSendPromptOpenChange}
invoice={createdCreditNote}
mode={sendMode}
onSuccess={() => undefined}
/>
)}
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
+114 -31
View File
@@ -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<string | null>(null)
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [vatRegistered, setVatRegistered] = useState<boolean>(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 (
<div className="space-y-8">
@@ -600,14 +641,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
)}
{invoice.status === 'draft' && !isDeliveryNote && invoice.invoice_number && (
customerHasEmail ? (
preferredSendMode === 'email' ? (
<Button
onClick={() => openSendDialog('email')}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Mail className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('send_via_email')}
{t(booksOnIssue ? 'send_via_email_and_book' : 'send_via_email')}
</Button>
) : (
<Button
@@ -617,10 +658,29 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('mark_sent_manually')}
{t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
</Button>
)
)}
{isCopyable && canWrite && (
<Link href={`/invoices?copy=${invoice.id}`}>
<Button variant="outline">
<Copy className="mr-2 h-4 w-4" />
{t('copy_invoice')}
</Button>
</Link>
)}
{creditNoteNeedsRepair && (
<Button
variant="secondary"
onClick={() => openSendDialog('manual')}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <AlertTriangle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('complete_credit_bookkeeping')}
</Button>
)}
{isDeliveryNote && invoice.status === 'draft' && (
<Button
variant="secondary"
@@ -632,7 +692,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{t('mark_as_sent')}
</Button>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && !isCreditNote && (
<Button
onClick={() => setShowPaymentDialog(true)}
disabled={isUpdating || !canWrite}
@@ -1079,7 +1139,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</CardTitle>
{reminders.length === 0 && (
<CardDescription>
{t('reminders_description')}
{t('reminders_description', {
day1: reminderDays[0],
day2: reminderDays[1],
day3: reminderDays[2],
})}
</CardDescription>
)}
</CardHeader>
@@ -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 && (
<Card className="border-warning/50">
{creditNote && (
<Card className={creditNote.status === 'draft' ? undefined : 'border-warning/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<CardTitle className={cn(
'flex items-center gap-2',
creditNote.status !== 'draft' && 'text-warning',
)}>
<ReceiptText className="h-5 w-5" />
{t('credited_card_title')}
{creditNote.status === 'draft'
? t('credit_draft_card_title')
: t('credited_card_title')}
</CardTitle>
</CardHeader>
<CardContent>
<Link href={`/invoices/${creditNote.id}`}>
<Button variant="outline" size="sm" className="w-full">
<ExternalLink className="mr-2 h-4 w-4" />
{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 ?? '' })}
</Button>
</Link>
</CardContent>
@@ -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') && (
<Card>
<CardHeader>
<CardTitle>{t('actions_card_title')}</CardTitle>
@@ -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' && (
<>
<Button
variant="ghost"
@@ -1247,7 +1318,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
onClick={() => openSendDialog('manual')}
>
<Send className="mr-2 h-4 w-4" />
{t('mark_sent_manually')}
{t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
</Button>
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
{t('send_manual_hint_with_email')}
@@ -1261,12 +1332,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('delete_draft')}
{t(isCreditNote ? 'remove_credit_draft' : 'delete_draft')}
</Button>
</>
)
)}
{((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice) && (
{((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice && !creditNote) && (
<Link href={`/invoices/${invoice.id}/credit`} className="block">
<Button variant="outline" className="w-full">
<ReceiptText className="mr-2 h-4 w-4" />
@@ -1280,15 +1351,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
</div>
{/* 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. */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{invoice.invoice_number ? t('delete_dialog_title') : t('remove_dialog_title')}</DialogTitle>
<DialogTitle>
{isCreditNote
? t('remove_credit_dialog_title')
: invoice.invoice_number
? t('delete_dialog_title')
: t('remove_dialog_title')}
</DialogTitle>
<DialogDescription>
{invoice.invoice_number ? (
{isCreditNote ? (
t('remove_credit_dialog_desc')
) : invoice.invoice_number ? (
<>
{t('delete_dialog_desc_with_number_1')}
<strong>{t('delete_dialog_status_makulerad')}</strong>
@@ -1310,7 +1389,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
<Button variant="destructive" onClick={deleteInvoice} disabled={isDeleting}>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{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')}
</Button>
</DialogFooter>
</DialogContent>
+3 -1
View File
@@ -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() {
<NewInvoiceDialog
open={showNewInvoice}
copyFromId={copyFromId}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
@@ -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() {
</Card>
)}
{invoice.document_id && (
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('document_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Paperclip className="h-4 w-4 shrink-0" />
<span>{t('document_attached')}</span>
</div>
<DocumentViewButton
documentId={invoice.document_id}
label={t('view_document')}
/>
</div>
</CardContent>
</Card>
)}
{/* Journal entries (sambandskrav) */}
<Card>
<CardHeader>
+5
View File
@@ -45,6 +45,7 @@ export default function PrivacyPolicyPage() {
<ul>
<li><strong>Kontouppgifter:</strong> E-postadress (för inloggning)</li>
<li><strong>Företagsuppgifter:</strong> Företagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Kundidentitet:</strong> Personnummer för privatkunder när det behövs för avtal eller fakturering</li>
<li><strong>Bokföringsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokföringsunderlag</li>
@@ -203,6 +204,10 @@ export default function PrivacyPolicyPage() {
<strong>Kontouppgifter:</strong> länge kontot är aktivt, plus 30 dagar efter
begäran om radering (för att hantera pågående bokföringsplikter).
</li>
<li>
<strong>Kundidentitet:</strong> Under kundrelationen, eller i sju år när uppgiften
ingår i räkenskapsinformation som måste bevaras.
</li>
<li>
<strong>Tekniska loggar:</strong> Maximalt 90 dagar.
</li>
+51 -14
View File
@@ -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 })
+63 -5
View File
@@ -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' },
})
})
})
+31 -2
View File
@@ -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<string, unknown> = {}
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 },
)
@@ -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)
})
})
+6 -3
View File
@@ -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 },
)
+52 -1
View File
@@ -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')
})
})
@@ -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({
+21 -1
View File
@@ -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,
@@ -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
+181 -40
View File
@@ -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 },
+20 -34
View File
@@ -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()
@@ -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 () => {
+116 -31
View File
@@ -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<string, unknown>).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<string, unknown>).accounting_method as string | undefined
let createdJournalEntryId: string | undefined
const accountingMethod = ((company as Record<string, unknown>).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 }
+33 -20
View File
@@ -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' } })
+107 -115
View File
@@ -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<string, string> }) => ({
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 })
}
+82
View File
@@ -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
+16 -1
View File
@@ -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 })
}
@@ -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' })
+60
View File
@@ -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',
@@ -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' }),
@@ -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' })
@@ -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,
@@ -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
@@ -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' &&
+21 -15
View File
@@ -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<HTMLInputElement>(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({
<Upload className={compact ? 'h-4 w-4 text-muted-foreground' : 'mx-auto h-8 w-8 text-muted-foreground'} />
<div>
<p className={compact ? 'text-sm text-muted-foreground' : 'text-sm font-medium'}>
{compact ? 'Dra och släpp eller klicka' : 'Dra och släpp filer här'}
{compact ? t('compact_prompt') : t('prompt')}
</p>
{!compact && (
<p className="text-xs text-muted-foreground">
PDF, bilder (max 10 MB)
{t('format_hint')}
</p>
)}
</div>
@@ -264,13 +270,13 @@ export default function DocumentUploadZone({
)}
{file.status === 'uploaded' && (
<Badge variant="success" className="text-xs px-1.5 py-0">
Uppladdad
{t('uploaded')}
</Badge>
)}
{file.status === 'error' && (
<>
<Badge variant="destructive" className="text-xs px-1.5 py-0">
Fel
{t('error')}
</Badge>
{file.error && (
<span className="text-xs text-destructive">{file.error}</span>
@@ -281,7 +287,7 @@ export default function DocumentUploadZone({
<Button
variant="ghost"
size="sm"
aria-label="Ta bort fil"
aria-label={t('remove_file')}
className="h-6 w-6 p-0 shrink-0"
onClick={(e) => {
e.stopPropagation()
@@ -297,7 +303,7 @@ export default function DocumentUploadZone({
)}
{isUploading && (
<p className="text-xs text-muted-foreground">Laddar upp...</p>
<p className="text-xs text-muted-foreground">{t('uploading')}</p>
)}
</div>
)
@@ -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}&current_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}&current_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<string>(
(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({
)}
<span className="truncate flex-1">{doc.file_name}</span>
{doc.referenced && (
<Badge variant="secondary" className="shrink-0">
{t('via_supplier_invoice')}
</Badge>
)}
<span className="text-xs text-muted-foreground shrink-0">
{formatFileSize(doc.file_size_bytes)}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
onClick={() => handleOpenReplacePicker(doc.id)}
disabled={isReplacing}
title={t('replace')}
aria-label={t('replace')}
>
{isReplacing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
{!doc.referenced && (
<>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
onClick={() => handleOpenReplacePicker(doc.id)}
disabled={isReplacing}
title={t('replace')}
aria-label={t('replace')}
>
{isReplacing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
onClick={() => handleRequestRemove(doc)}
title={t('remove')}
aria-label={t('remove')}
>
<Trash2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
onClick={() => handleRequestRemove(doc)}
title={t('remove')}
aria-label={t('remove')}
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
<Button
variant="ghost"
+8 -4
View File
@@ -50,7 +50,7 @@ export default function CustomerForm({
vat_number: z.string().optional(),
personal_number: z
.string()
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, t('personal_number_invalid'))
.regex(/^(?:(\d{6}|\d{8})[-+]?\d{4}|\*{8}-\d{4})$/, t('personal_number_invalid'))
.optional()
.or(z.literal('')),
language: z.enum(['sv', 'en']).optional(),
@@ -133,11 +133,15 @@ export default function CustomerForm({
}
const onFormSubmit = (data: FormData) => {
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 (
+47 -7
View File
@@ -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, string>): 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<boolean>(
typeof initial?.ore_rounding === 'boolean' ? initial.ore_rounding : true,
typeof initialOreRounding === 'boolean' ? initialOreRounding : true,
)
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
const [numberPreview, setNumberPreview] = useState<string | null>(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<Record<string, string>>(
initial?.default_dimensions ?? {},
initial?.default_dimensions ?? copyInitial?.default_dimensions ?? {},
)
const [dimensionOverrideRows, setDimensionOverrideRows] = useState<Set<number>>(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
/>
</div>
{!isEditMode && (
{isCopyMode && copyInitial && (
<div className="flex items-start gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-sm">
<Copy className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">
{t('copy_notice', { number: copyInitial.source_invoice_number })}
</p>
</div>
)}
{!isEditMode && !isCopyMode && (
<Tabs value={mode} onValueChange={(v) => setMode(v as 'invoice' | 'self_billed')}>
<TabsList>
<TabsTrigger value="invoice">{t('mode_invoice')}</TabsTrigger>
+100 -4
View File
@@ -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<string, unknown>[] = []
if (!error && data) {
try {
items = await fetchAllRows<Record<string, unknown>>(({ 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -50,8 +124,30 @@ export default function NewInvoiceDialog({ open, onOpenChange }: Props) {
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{t('title_invoice')}</DialogTitle>
<InvoiceEditor mode="create" bare />
<DialogTitle className="sr-only">
{copyFromId ? t('title_copy') : t('title_invoice')}
</DialogTitle>
{copyFromId ? (
copyLoadFailed ? (
<div className="space-y-4 p-6 text-center">
<p className="font-medium">{t('copy_load_failed_title')}</p>
<p className="text-sm text-muted-foreground">{t('copy_load_failed_description')}</p>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('close')}
</Button>
</div>
) : copyInitial ? (
<InvoiceEditor key={copyFromId} mode="copy" initial={copyInitial} bare />
) : (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)
) : (
<InvoiceEditor mode="create" bare />
)}
</DialogContent>
</Dialog>
)
+118 -39
View File
@@ -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<EntityType>('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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{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 }) : ''}
</DialogTitle>
<DialogDescription>
{formatCurrency(invoice.total, invoice.currency)}
@@ -236,7 +297,7 @@ export default function SendInvoiceDialog({
<JournalEntryReviewContent
periodName={periodName}
entryDate={invoice.invoice_date}
description={t('voucher_description', {
description={t(isCreditNote ? 'credit_voucher_description' : 'voucher_description', {
numberSpace: invoice.invoice_number ? ` ${invoice.invoice_number}` : '',
customerSuffix: invoice.customer.name ? `, ${invoice.customer.name}` : '',
})}
@@ -249,8 +310,8 @@ export default function SendInvoiceDialog({
</>
) : (
<p className="text-sm text-muted-foreground">
{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')}
</Button>
<Button
onClick={handleConfirm}
@@ -287,7 +348,25 @@ export default function SendInvoiceDialog({
) : (
<Send className="mr-2 h-4 w-4" />
)}
{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',
)}
</Button>
</DialogFooter>
</DialogContent>
+1 -1
View File
@@ -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,
},
{
@@ -92,6 +92,46 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
{t('default_our_reference_help')}
</p>
</div>
<fieldset className="space-y-4 border-t border-border pt-4">
<legend className="text-sm font-medium">{t('reminder_days_heading')}</legend>
<p className="text-xs text-muted-foreground">{t('reminder_days_help')}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="reminder_days_level_1">{t('reminder_days_level_1')}</Label>
<Input
id="reminder_days_level_1"
name="reminder_days_level_1"
type="number"
min="1"
max="365"
defaultValue={settings.reminder_days_level_1 ?? 15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder_days_level_2">{t('reminder_days_level_2')}</Label>
<Input
id="reminder_days_level_2"
name="reminder_days_level_2"
type="number"
min="1"
max="365"
defaultValue={settings.reminder_days_level_2 ?? 30}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder_days_level_3">{t('reminder_days_level_3')}</Label>
<Input
id="reminder_days_level_3"
name="reminder_days_level_3"
type="number"
min="1"
max="365"
defaultValue={settings.reminder_days_level_3 ?? 45}
/>
</div>
</div>
</fieldset>
</section>
)
}
@@ -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,
@@ -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<string | null>(null)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [newSupplier, setNewSupplier] = useState<NewSupplierForm>(EMPTY_NEW_SUPPLIER)
const [documentFiles, setDocumentFiles] = useState<UploadedFile[]>([])
const documentFilesRef = useRef<UploadedFile[]>([])
const createFinishedRef = useRef(false)
// Inbox/AI state
const [extractedData, setExtractedData] = useState<InvoiceExtractionResult | null>(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({
)}
</div>
{!inboxItemId && (
<div className="space-y-3 rounded-lg border border-border p-4">
<div className="flex items-start gap-3">
<Paperclip className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<Label>{t('document_label')}</Label>
<p className="text-xs text-muted-foreground">{t('document_help')}</p>
</div>
</div>
<DocumentUploadZone
files={documentFiles}
onFilesChange={handleDocumentFilesChange}
maxFiles={1}
disabled={isSubmitting}
compact
/>
</div>
)}
{/* 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')}
</Button>
@@ -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({
)}
<Button
type="submit"
disabled={isSubmitting || !canWrite || showNoPeriodWarning}
disabled={isSubmitting || documentUploadInProgress || !canWrite || showNoPeriodWarning}
className="w-full sm:w-auto"
onClick={() => { submitModeRef.current = 'register' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
@@ -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 })
@@ -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 })
+1 -1
View File
@@ -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."
}
}
@@ -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,
})),
}
})
@@ -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<string, ReturnType<typeof vi.fn>> = {
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 },
},
})
})
})
@@ -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<string, unknown>
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,
})
})
})
@@ -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<string, unknown> | 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<typeof import('@/lib/auth/api-keys')>()
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<string, ReturnType<typeof vi.fn>> = {
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<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, hasCapability: mocks.hasCapability }
})
import { handleMcpRequest } from '../server'
function toolCall(args: Record<string, unknown>): 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<string, unknown>,
}
}
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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).code).toBe('VALIDATION_ERROR')
expect(mocks.companyIds).toEqual([])
expect(mocks.hasCapability).not.toHaveBeenCalled()
})
})
@@ -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)
})
})
@@ -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 ──
@@ -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<string, unknown> }).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<string, unknown> }).properties
).not.toHaveProperty('company_id')
})
it('filters by query keyword', async () => {
@@ -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([])
})
})
@@ -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<string, ReturnType<typeof vi.fn>> = {
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
@@ -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,
})),
}
})
@@ -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()),
}
}),
}
})
@@ -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<string, unknown>
}
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<string, unknown> {
if (!isCompanyDependentTool(tool.name)) return tool.inputSchema
const properties =
tool.inputSchema.properties && typeof tool.inputSchema.properties === 'object'
? (tool.inputSchema.properties as Record<string, unknown>)
: {}
return {
...tool.inputSchema,
properties: {
...properties,
company_id: COMPANY_ID_INPUT_PROPERTY,
},
}
}
export function extractRequestedCompany(
rawArgs: Record<string, unknown>
): { requestedCompanyId: string | undefined; toolArgs: Record<string, unknown> } {
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<McpCompanyContext> {
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<string, unknown>
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<string, unknown>)
: {}
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<string, unknown>
if (!record.next) return result
return {
...record,
next: addCompanyToNextHint(record.next, companyId),
}
}
@@ -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)
+185 -35
View File
@@ -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<string, unknown>,
args: { operation_id: data.id, company_id: companyId } as Record<string, unknown>,
},
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<string, string>()
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<Response> {
'',
'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<Response> {
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<Response> {
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string
const toolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
const rawToolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
string,
unknown
>
@@ -13085,12 +13191,55 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
)
}
let toolArgs: Record<string, unknown>
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<Response> {
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<Response> {
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<Response> {
// 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<Response> {
if (toolName === 'gnubok_search_tools') {
(toolArgs as Record<string, unknown>).__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<string, unknown> = {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -13208,7 +13358,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
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<Response> {
errorMessage: structured.error.message_sv,
requestId: id ?? null,
userId,
companyId,
companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
+18
View File
@@ -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({
+32 -1
View File
@@ -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
+2
View File
@@ -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<string, ApiKeyScope> = {
// Companies
gnubok_list_companies: 'companies:read',
// Transactions
gnubok_list_uncategorized_transactions: 'transactions:read',
gnubok_list_transactions_without_documents: 'transactions:read',
@@ -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', () => {
@@ -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<string, string> | 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' }
+26 -7
View File
@@ -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 = {
+66 -3
View File
@@ -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<string, string> | 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
+21 -19
View File
@@ -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),
)
}
/**
@@ -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
@@ -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<string, string>()
// 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<InvoiceRow>(({ 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<string>()
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<InvoiceRow>(({ 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<string, string>()
const supplierInvoices = new Map<string, { number: string; documentId?: string }>()
// 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<SupplierInvoiceRow>(({ 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<SupplierInvoiceRow>(({ 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<string>()
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<SupplierInvoiceRow>(({ 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
}
+8
View File
@@ -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
}
+21
View File
@@ -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<T extends { personal_number?: string | null }>(row: T): T {
return {
...row,
personal_number: maskStoredCustomerPersonalNumber(row.personal_number),
}
}
+27 -3
View File
@@ -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
}
+72
View File
@@ -428,6 +428,11 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
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).',
+4
View File
@@ -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, unknown>): 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) {
+2 -1
View File
@@ -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".
+1 -1
View File
@@ -23,7 +23,7 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"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",
@@ -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> = {}): 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' },
})
})
})
+108
View File
@@ -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> = {}): 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')
})
})
@@ -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 })
})
})
@@ -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')
})
})
@@ -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({
@@ -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)
})
})
@@ -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<CreditNote> = {}) {
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([])
})
})
@@ -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<string> {
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' })
})
})
@@ -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', () => {
@@ -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
+31
View File
@@ -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 ?? {},
}
}
+108
View File
@@ -0,0 +1,108 @@
import type {
Currency,
Invoice,
InvoiceDocumentType,
InvoiceItem,
InvoiceStatus,
} from '@/types'
const COPYABLE_STATUSES: ReadonlySet<InvoiceStatus> = 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<string, string> | 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<string, string>
items: InvoiceCopyItem[]
}
export function canCopyInvoice(
invoice: Pick<Invoice, 'status' | 'document_type' | 'credited_invoice_id' | 'is_self_billed'>,
): 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,
})),
}
}
+9
View File
@@ -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'
}
+37 -20
View File
@@ -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<Invoice & { customer?: { name?: string | null } | null }>
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<string>()
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 []
}
+10 -2
View File
@@ -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
)
}
+280
View File
@@ -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<string | undefined> {
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<string | null> {
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<IssueCreditNoteResult> {
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 }
}
+54 -29
View File
@@ -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<ProcessRemindersResult> {
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<ProcessRemindersResult>
.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<ProcessRemindersResult>
// 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<ProcessRemindersResult>
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<ProcessRemindersResult>
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<ProcessRemindersResult>
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
}
+9
View File
@@ -81,6 +81,7 @@ export type SettleInvoicePaymentResult =
| { ok: false; code: 'INVOICE_PAID_LINES_UNBALANCED'; details: Record<string, unknown> }
| { ok: false; code: 'INVOICE_PAID_NO_FISCAL_PERIOD'; details: Record<string, unknown> }
| { ok: false; code: 'INVOICE_PAID_BOOK_FAILED'; details: Record<string, unknown> }
| { ok: false; code: 'INVOICE_PAID_NOT_PAYABLE'; details: Record<string, unknown> }
| { 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

Some files were not shown because too many files have changed in this diff Show More