diff --git a/.compliance/Data_Classification_Handling.md b/.compliance/Data_Classification_Handling.md new file mode 100644 index 00000000..39844fc1 --- /dev/null +++ b/.compliance/Data_Classification_Handling.md @@ -0,0 +1,25 @@ +# Data Classification and Handling + +## Restricted data + +Swedish personal identity numbers are Restricted personal data. They are not an +Article 9 special category by themselves, but their stable government identifier +role requires heightened protection. + +Controls: + +- Customer personal numbers are accepted only for individual customers. +- Values are encrypted with AES-256-GCM before database storage. +- API and UI output exposes only the last four digits. +- Writes require an authenticated company member with write permission. +- RLS and explicit `company_id` filters enforce tenant isolation. +- There is no endpoint that returns the full value. +- Logs and audit event payloads must never contain the full value. + +## Internal business data + +Article master records are Internal business data. An unused article may be +deleted because issued invoice lines, archived invoice PDFs, journal entries, +and audit events retain the accounting evidence independently. Any article that +is referenced by an invoice line is protected by the application check and the +database foreign key. diff --git a/.compliance/dsar_runbook.md b/.compliance/dsar_runbook.md new file mode 100644 index 00000000..60c00eb8 --- /dev/null +++ b/.compliance/dsar_runbook.md @@ -0,0 +1,23 @@ +# Data Subject Rights Runbook + +## Customer identity data + +For access, correction, restriction, portability, or erasure requests, first +verify the requester and the tenant relationship. Search the company-scoped +customer record and any retained accounting documents. Customer master data may +be corrected or erased when no legal retention duty applies. Accounting records +and issued invoice documents remain retained for the statutory period; document +the Article 17(3)(b) exception in the response. + +Full personal numbers are never returned through the ordinary customer API. +Exports and support evidence must use the masked value unless a separately +approved identity-verification procedure requires otherwise. + +## Article master data + +Article master records are not personal data and have no independent retention +duty. The delete endpoint allows deletion only when no invoice item references +the article. Issued invoice lines contain frozen descriptions, accounts, VAT +values, and amounts, while archived PDFs and journal records remain immutable. +This preserves the verification chain and the seven-year accounting retention +period even when an unused article master row is deleted. diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index 2557a49c..a10779af 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -8,6 +8,41 @@ processing_activities: + - id: customer.private_identity + name: Personnummer för privatkund + purpose: >- + Identifiera en privatkund när företagets avtal, fakturering eller + kundadministration kräver en entydig identitet. Personnumret får endast + sparas på kundtypen privatkund och exponeras endast maskerat i API och UI. + lawful_basis: art_6_1_b + special_category_basis: null + controller: gnubok-tenant + processor: supabase + data_subjects: + - customer + data_categories: + - user.government_id + recipients: + - name: Supabase + country: EU + role: processor + international_transfers: + applicable: false + mechanism: null + note: EU-only processor. + retention: + duration: customer_relationship_or_7y_if_accounting_record + basis: contract_and_bfl_7_kap + stored_in: + - customers.personal_number + - retained_invoice_documents_when_required + security_measures: + - aes_256_gcm_field_encryption + - masked_api_and_ui_output_last4_only + - write_role_required + - rls_company_scoped + - no_full_value_read_endpoint + - id: agi.submit name: AGI inlämning till Skatteverket purpose: >- diff --git a/.gitignore b/.gitignore index e6c7f4a7..863f28b5 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,5 @@ scripts/reopen-bokslut.sql .claude/plans/write-up-a-plan-streamed-fiddle.md /ingaende-balanser-test.csv +.agents +.codex \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9c8c3f73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,106 @@ +# CLAUDE.md: Accounted + +Swedish accounting SaaS: double-entry bookkeeping under Swedish accounting law (Bokföringslagen) for sole traders (enskild firma) and limited companies (aktiebolag). Multi-tenant: users belong to companies via `company_members`; `teams` group companies for consultants. + +**Stack**: Next.js 16 (App Router), React 19, TypeScript 5 strict, Zod 4, Supabase (Postgres + RLS + auth), Tailwind 4 + shadcn/ui. Vercel-hosted is the primary target; Docker self-hosted must keep working but never at hosted's expense. Path alias `@/*` = repo root. All code, comments, and commits in English. + +--- + +## Hard Rules + +The accounting rules are Swedish law, enforced by DB triggers. Code that violates them fails at runtime; code that works around the triggers breaks legal compliance. Never do either. + +1. **Never edit or delete a posted journal entry.** Committed vouchers are immutable. Cancel with `reverseEntry()`; correct with `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`). Storno, never edit. +2. **All journal writes go through `lib/bookkeeping/engine.ts`.** Never insert into journal tables directly: voucher numbers are assigned atomically by the `commit_journal_entry` RPC and must stay sequential, and gaps require documented explanations (BFNAR 2013:2, `voucher_gap_explanations`). +3. **Every entry balances**: `sum(debits) === sum(credits)`, both `> 0`. +4. **Respect period locks.** DB triggers block writes to closed/locked periods and behind the company lock date. Don't work around them: fix the flow that tried to write there. +5. **Never delete documents linked to posted entries**: 7-year retention is a legal requirement. +6. **Money math is `Math.round(x * 100) / 100`.** Never `toFixed()`: it returns strings and rounds incorrectly, causing öre-level drift that breaks entry balance. +7. **Account numbers are strings** (`'1930'`, never `1930`). They are identifiers, not quantities; arithmetic on them is always a bug. + +General prohibitions: + +- **Never modify an existing migration**: schemas already shipped; create a new migration. Never touch the enforcement triggers (migration 017); they are legally required. +- **Never leave a remote DB ahead of the repo.** If you `apply_migration` (or run any DDL) against prod, staging, or a preview branch, write the byte-identical SQL into `supabase/migrations/` under the exact applied version in the same change. An applied version with no committed file is an orphan: Supabase branching aborts the next merge to `main` with "Remote migration versions not found in local migrations directory" and blocks every pending migration behind it. The PR preview passes anyway (preview branches fork from prod's history, which already has the orphan), so this only surfaces at merge. +- **Core code must never import from `@/extensions/`.** CI builds core with zero extensions enabled; a direct import breaks that build. Extensions cannot use dynamic imports (the registry generates static imports via `setup:extensions`). +- **Don't add dependencies without asking.** This is an AGPL-3.0 project; license compatibility matters, and the dependency surface is audited. +- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, `gnubok-mcp` npm package. Renaming them breaks live sessions, API keys, and invites. +- **Treat `.env.local` as pointing at the production database.** Never run seed/cleanup/repair scripts against it without explicit confirmation. +- **Keep the diff scoped to the request.** No drive-by refactors of untouched code. +- **Never use em dashes (—) or en dashes (–)** in code, comments, commit messages, or docs. Use a colon, comma, semicolon, or plain hyphen instead, whichever fits the sentence. Exception: a dash character that is the literal subject being parsed, matched, or documented (e.g. mojibake byte-mapping tables, a date-range separator regex) stays as-is; don't launder those into a colon. +- Never create a NUL/nul file: `\Accounted\NUL`. + +## When Uncertain + +- **Stop and ask; do not guess.** Especially for anything touching posted entries, the production database, money math, or Swedish tax law. +- **Swedish domain questions are never answered from training data.** Load the matching `swedish-*` skill (vat, accounting-compliance, invoice-compliance, payroll, year-end-closing, sie-import-export, sru-filing, financial-reporting, asset-accounting, project-accounting, tax-planning, e-invoicing). +- Scaffolding has skills; use them instead of improvising: `/erp-api-route` (API routes), `/supabase-migration` (migrations), `/create-extension` (extensions), `/frontend-design` (new UI), `vercel:deploy` (deployment). + +## Definition of Done + +A change is done when all of these hold; iterate until they do: + +1. `npm run lint` is clean and `npm test` passes (`npx vitest run ` while iterating). +2. New or changed logic in `lib/` or `app/api/` has tests: auth 401, validation 400, 404, happy path; mock `@/lib/supabase/server`. +3. Any change to a trigger, RPC, RLS policy, or DEFERRABLE constraint ships with a `*.pg.test.ts` (`npm run test:pg`). +4. New UI strings exist in **both** `messages/sv.json` and `messages/en.json`. +5. If you edited an atom `SKILL.md`, `npm run skills:generate` was run (CI's `skills:check` fails otherwise). +6. `npm run check:guards` passes if you touched API routes. +7. Commit is conventional (`feat:`/`fix:`/`refactor:`/`test:`/`docs:`), atomic, branched from `main`. +8. If the change touches migrations, local and prod are reconciled: every version in prod's `schema_migrations` has a matching file in `supabase/migrations/`, and vice versa. Check before opening the PR (e.g. `list_migrations` / `select version from supabase_migrations.schema_migrations`); a remote-only version means an uncommitted orphan that will fail the merge. + +## Commands + +```bash +npm run dev # Dev server (runs setup:extensions first) +npm run build # Production build (runs setup:extensions first) +npm run lint # ESLint +npm test # All Vitest tests +npx vitest run # Tests in one directory +npm run test:pg # pg-real tests against real Postgres +npm run check:guards # Ratchet guard (e.g. no hand-rolled route auth) +npm run setup:extensions # Regenerate extension registry from extensions.config.json +npm run skills:generate # Regenerate agent_atom_registry seed after editing an atom SKILL.md +``` + +## Architecture + +- **Journal entry lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC); `createJournalEntry()` does both. Everything accounting-shaped routes through this engine. +- **Tenancy**: every business table has `company_id`. Active company resolves in `lib/supabase/middleware.ts`: `gnubok-company-id` cookie → `user_preferences.active_company_id` → first membership. RLS uses `user_company_ids()`; queries still filter by `company_id` explicitly (defense in depth: service-role paths have no RLS). +- **Auth**: Supabase email+password + TOTP MFA, enforced **application-side**, not in RLS. `NEXT_PUBLIC_REQUIRE_MFA=true` on hosted; `NEXT_PUBLIC_SELF_HOSTED=true` disables MFA. API routes wrap `withRouteContext`: it is the only path that enforces MFA, so never hand-roll `supabase.auth.getUser()` in a route. +- **Events**: `lib/events/bus.ts` is a module-level singleton. Any route that emits events must call `ensureInitialized()` (`lib/init.ts`) at module level: otherwise extension handlers are never wired and events silently go nowhere. +- **Supabase clients**: browser `client.ts`, server `createClient()`, service role `createServiceClient()`, cookieless service role `createServiceClientNoCookies()` (lives in `lib/auth/api-keys.ts`; for API-key/MCP paths). Paginate with `fetchAllRows()`: PostgREST silently caps at 1000 rows. +- **Extensions**: opt-in plugins in `extensions/general//`; `extensions.config.json` is the source of truth for what's enabled. Core must run with zero extensions. +- **MCP server**: the bookkeeping engine is exposed as 100+ MCP tools (`extensions/general/mcp-server/`), authenticated by `gnubok_sk_` API keys (SHA-256, scoped, default 100 RPM per key). +- **Types**: import from `@/types` (`types/index.ts`); event types in `lib/events/types.ts`. +- **User-facing errors are Swedish**: map through `lib/errors/get-error-message.ts`. +- **Cron**: hosted cron jobs live in `vercel.json`, authenticated via `verifyCronSecret()` (`lib/auth/cron.ts`). + +## Repository Map + +- `lib/bookkeeping/`: engine, entry generators, mapping, templates, BAS 2026 data (`bas-data/`) +- `lib/core/`: period, year-end, storno, tax codes, audit, documents +- `lib/events/`, `lib/auth/`, `lib/supabase/`, `lib/api/` (Zod `validateBody`/`validateQuery`) +- `lib/reports/`: balance sheet, income statement, trial balance, GL, ledgers, VAT, SIE, INK2, NE-bilaga, salary, … +- `lib/invoices/`, `lib/transactions/`, `lib/import/`, `lib/documents/`, `lib/salary/`, `lib/reconciliation/`, `lib/tax/`, `lib/vat/`, `lib/providers/` (Fortnox/Bokio/Briox/BL/Visma), `lib/skatteverket/`, `lib/currency/`, `lib/bankgiro/`, `lib/deadlines/`, `lib/calendar/` +- `lib/utils.ts`: `cn()`, `formatCurrency()`, `formatDate()`, `formatOrgNumber()`; `lib/logger.ts` +- `app/(dashboard)/*` pages; `app/api/*` routes; `supabase/migrations/` schema; `extensions/general/*` plugins + +## Testing + +Vitest 4, `node` env, tests in `__tests__/`, scope `lib/` + `app/api/` (no component/E2E tests). Helpers in `tests/helpers.ts`: `createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest()`, `parseJsonResponse()`, plus fixture factories (`makeTransaction`, `makeJournalEntry`, `makeInvoice`, …). `vi.clearAllMocks()` + `eventBus.clear()` in `beforeEach`. Trigger/RPC/RLS behavior is tested in `*.pg.test.ts` against real Postgres, not with mocks. + +## Detail Loads On Demand + +The files below are the shared source of truth for path-specific guidance. Claude Code loads them through their `paths` frontmatter. Codex does not interpret that frontmatter, so before reading, editing, reviewing, or otherwise working with a matching path, read and follow the listed rule. Do not duplicate the rule bodies here. + +- `.claude/rules/design.md`: design system, locked tokens (`app/**`, `components/**`) +- `.claude/rules/i18n.md`: sv/en conventions, "stays Swedish" surfaces +- `.claude/rules/api-routes.md`: `withRouteContext` route pattern, endpoint map (`app/api/**`) +- `.claude/rules/database.md`: migration rules, key tables/RPCs/triggers, pg-real (`supabase/migrations/**`) +- `.claude/rules/mcp-server.md`: MCP tool authoring, staged-operation pattern +- `.claude/rules/bookkeeping.md`: BAS accounts, VAT treatments/rutor, `lib/core/` services + +## Decision Log + +When you make a non-obvious choice (picked approach A over B, declined a dependency, stopped because a rule here forbade something), append one line to `DECISIONS.md` (repo root): `[YYYY-MM-DD] : `. Check that file before re-litigating a past decision. diff --git a/DECISIONS.md b/DECISIONS.md index 49cb6690..6033f7e9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -150,4 +150,17 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-13] employee_opening_balances created_by preserved via read-then-upsert, not a DB trigger: a BEFORE UPDATE trigger would need a new migration for a pure audit concern; the extra select is one indexed query and the lock trigger already backstops races. [2026-07-13] Opening balances are authoritative for pre-cutover YTD: runSalaryCalculation now excludes booked runs before the cutover month from the YTD aggregation for employees with opening balances, instead of blocking pre-cutover backdated runs (backfill of history is a supported flow). [2026-07-13] Superseded the 2026-07-13 decline of the NOT VALID suggestion for migration 20260713100000: Emil asked to resolve the PR findings, and the migration is branch-only (verified absent from prod schema_migrations), so the never-modify-shipped-migrations rule does not apply; staging already recorded the versions, so edits only change what prod runs at merge. Implemented as ADD ... NOT VALID in 20260713100000 + 20260713121000 with VALIDATE split into 20260713123000: VALIDATE in the same transaction as ADD would be a no-op since Postgres holds the ACCESS EXCLUSIVE lock until commit; a separate migration file gets its own transaction and validates under SHARE UPDATE EXCLUSIVE. 20260713123000 applied to staging (no-op VALIDATE) and version recorded. +[2026-07-14] Codex path-specific guidance stays in the single root AGENTS.md, which dispatches to the shared .claude/rules sources: nested AGENTS.md files were declined to keep one project instruction file, and .codex/config.toml has no Claude-style paths matcher. +[2026-07-14] MCP company selection is stateless per tool call with shared API-key scopes: membership and viewer write access are rechecked for the selected company, while per-company scope overrides are deferred to avoid stateful connection races and premature configuration complexity. +[2026-07-14] Credit notes are blocked from paid and partially_paid states with early TypeScript guards plus a database CHECK: early guards prevent orphan payment vouchers and provide clear errors, while the constraint protects every remaining RPC and legacy caller. Production had zero existing violations in a count-only audit, so ADD NOT VALID and VALIDATE ship as separate migrations to avoid scanning under the stronger ADD lock. +[2026-07-14] Automatic invoice reminder timing uses three strictly increasing company settings from 1 through 365 days, defaulting to 15, 30, and 45: separate columns keep validation and settings forms explicit, while the cron falls back to the legacy schedule if it encounters invalid legacy data. +[2026-07-14] Credit notes are created as non-editable but hard-deletable drafts and issue through a compare-and-set plus an idempotent bookkeeping repair path: this preserves the original until the reversing voucher and accrual storno are durable, lets failed drafts be recreated with the same KR number, and blocks the generic MCP invoice executors from bypassing the lifecycle. +[2026-07-14] Invoice send actions mention bookkeeping only when issuance creates a journal entry: accrual invoices and ledger-reversing credit notes say send and post, while ordinary cash-method invoices say only send because they are posted at payment. +[2026-07-15] Article DELETE performs a company-scoped existence check and blocks when any invoice item references the article before hard deletion; legacy inactive articles remain visible in the register so users can reach and delete them: invoice lines retain frozen accounting values, unused article master data has no retention requirement, and the article-number counter is deliberately not rewound because gaps are harmless. +[2026-07-15] Invoice copies start as new drafts and omit dates, lifecycle state, payment links, accrual periods, customer references, and recipient-specific ROT/RUT data: copied invoices must never inherit bookkeeping or stale customer-specific data from the issued original. +[2026-07-15] Manual supplier invoice uploads reuse the existing WORM document archive and supplier_invoices.document_id: accrual and privately paid invoices link the document to their registration-time journal entry, while cash-method invoices retain it on the invoice until the existing payment flow links it to the payment entry. +[2026-07-15] Supplier payment vouchers surface the supplier invoice PDF as a read-only referenced document instead of moving or duplicating its journal link: the retained source stays on the original registration voucher while the full verification chain remains directly reviewable from the payment voucher. [2026-07-15] Stornoing an opening_balance entry now clears fiscal_periods.opening_balance_entry_id inside reverseEntry, rather than making the year-end gate skip reversed IB entries: a status-aware gate alone is strictly worse, because the close would then proceed to generateOpeningBalances, whose bare UPDATE of opening_balance_entry_id is rejected by enforce_opening_balance_immutability while the old pointer is still set, trading a clear blocker for an opaque Postgres exception after the period is already locked and closed. Clearing at storno time also lets getOpeningBalances fall through to the duplicate-safe compute_prior_opening_balances RPC (it has no status filter and would otherwise keep rendering a cancelled IB), and mirrors the bank-transaction unlink already in reverseEntry. The write is two statements (flag, then pointer) because the trigger reads OLD.opening_balances_set; same order as replace_period_opening_balance_link. Not fixed by refusing to storno a linked IB (the other candidate): the user's goal was to remove a bogus IB so bokslut could re-book it, and the IB-correct flow can only replace, never remove. +[2026-07-15] Superseded the hard deletion part of the 2026-07-14 credit note draft decision: numbered credit note drafts are now retained as cancelled rows and reopened on retry so the KR series remains complete. +[2026-07-15] Customer personnummer uses application field encryption with masked API and UI output rather than a database-only cipher: the existing key custody and AES-256-GCM implementation can protect values before they reach Postgres, while ordinary reads never expose the full identifier. +[2026-07-15] Credit note creation uses a completion marker plus unique company guards instead of a large creation RPC: incomplete parents are never returned, concurrent requests converge, and all journal writes remain in the bookkeeping engine. diff --git a/app/(dashboard)/articles/[id]/page.tsx b/app/(dashboard)/articles/[id]/page.tsx index 36be3f3f..074b66da 100644 --- a/app/(dashboard)/articles/[id]/page.tsx +++ b/app/(dashboard)/articles/[id]/page.tsx @@ -22,7 +22,7 @@ import { Package, Wrench, Edit2, - Archive, + Trash2, Loader2, Lock, } from 'lucide-react' @@ -55,6 +55,7 @@ export default function ArticleDetailPage({ const [isLoading, setIsLoading] = useState(true) const [isEditOpen, setIsEditOpen] = useState(false) const [isUpdating, setIsUpdating] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm() useEffect(() => { @@ -127,36 +128,38 @@ export default function ArticleDetailPage({ } } - async function handleDeactivate() { + async function handleDelete() { if (!article) return const ok = await confirmAction({ - title: t('deactivate_confirm_title', { name: article.name }), - description: t('deactivate_confirm_description'), - confirmLabel: t('deactivate_confirm_label'), + title: t('delete_confirm_title', { name: article.name }), + description: t('delete_confirm_description'), + confirmLabel: t('delete_confirm_label'), variant: 'destructive', }) if (!ok) return + setIsDeleting(true) try { const response = await fetch(`/api/articles/${id}`, { method: 'DELETE', }) - if (!response.ok) { - throw new Error('Deactivate failed') - } + await throwOnStructuredError(response) toast({ - title: t('deactivated_title'), + title: t('deleted_title'), description: article.name, }) router.push('/articles') - } catch { + } catch (err) { + const body = (err as { body?: unknown }).body toast({ - title: t('deactivate_failed_title'), - description: t('retry'), + title: t('delete_failed_title'), + description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }), variant: 'destructive', }) + } finally { + setIsDeleting(false) } } @@ -175,7 +178,7 @@ export default function ArticleDetailPage({ return (
{/* Header */} -
+
-
+
- {article.active && ( - - )} +
diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx index acfd241c..d46bff55 100644 --- a/app/(dashboard)/articles/page.tsx +++ b/app/(dashboard)/articles/page.tsx @@ -102,7 +102,6 @@ function ArticlesPageInner() { .from('articles') .select('*') .eq('company_id', company.id) - .eq('active', true) .order('name', { ascending: true }) if (error) { diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx index 7887f8a0..12389cf5 100644 --- a/app/(dashboard)/customers/[id]/page.tsx +++ b/app/(dashboard)/customers/[id]/page.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' +import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number' import CustomerForm from '@/components/customers/CustomerForm' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { @@ -275,7 +276,7 @@ export default function CustomerDetailPage({ - {/* Business details */} + {/* Customer details */} {t('section_business')} @@ -287,12 +288,20 @@ export default function CustomerDetailPage({ {customer.customer_number}
)} - {customer.org_number && ( + {customer.customer_type !== 'individual' && customer.org_number && (
{t('label_org_number')} {customer.org_number}
)} + {customer.customer_type === 'individual' && (customer.personal_number || customer.org_number) && ( +
+ {t('label_personal_number')} + + {maskCustomerPersonalNumber(customer.personal_number || customer.org_number)} + +
+ )} {customer.vat_number && (
{t('label_vat')} @@ -306,7 +315,7 @@ export default function CustomerDetailPage({ {t('label_payment_terms')} {t('payment_terms_value', { days: customer.default_payment_terms || 30 })}
- {!customer.customer_number && !customer.org_number && !customer.vat_number && ( + {!customer.customer_number && !customer.org_number && !customer.personal_number && !customer.vat_number && (

{t('no_business_info')}

)} @@ -409,6 +418,7 @@ export default function CustomerDetailPage({ country: customer.country || undefined, org_number: customer.org_number || undefined, vat_number: customer.vat_number || undefined, + personal_number: customer.personal_number || undefined, default_payment_terms: customer.default_payment_terms || undefined, notes: customer.notes || undefined, }} diff --git a/app/(dashboard)/invoices/[id]/credit/page.tsx b/app/(dashboard)/invoices/[id]/credit/page.tsx index 03adc040..5abba896 100644 --- a/app/(dashboard)/invoices/[id]/credit/page.tsx +++ b/app/(dashboard)/invoices/[id]/credit/page.tsx @@ -15,6 +15,10 @@ import { cn, formatCurrency, formatDate } from '@/lib/utils' import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules' import { Loader2, ArrowLeft, AlertTriangle, Lock } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' +import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog' +import { useCompany, useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode' import type { Invoice, InvoiceItem, Customer } from '@/types' interface InvoiceWithRelations extends Invoice { @@ -24,6 +28,8 @@ interface InvoiceWithRelations extends Invoice { export default function CreateCreditNotePage({ params }: { params: Promise<{ id: string }> }) { const { canWrite } = useCanWrite() + const { isSandbox } = useCompany() + const canEmail = useCapability(CAPABILITY.email_send) const { id } = use(params) const router = useRouter() const { toast } = useToast() @@ -35,6 +41,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: const [isSubmitting, setIsSubmitting] = useState(false) const [reason, setReason] = useState('') const [confirmText, setConfirmText] = useState('') + const [createdCreditNote, setCreatedCreditNote] = useState(null) + const [showSendPrompt, setShowSendPrompt] = useState(false) useEffect(() => { fetchInvoice() @@ -114,14 +122,17 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: throw new Error(data.error || t('create_failed_fallback')) } - const { data: creditNote } = await response.json() + const { data: creditNote } = await response.json() as { data: InvoiceWithRelations } toast({ title: t('created_toast_title'), - description: t('created_toast_description', { number: creditNote.invoice_number }), + description: creditNote.invoice_number + ? t('created_toast_description', { number: creditNote.invoice_number }) + : undefined, }) - router.push(`/invoices/${creditNote.id}`) + setCreatedCreditNote(creditNote) + setShowSendPrompt(true) } catch (error) { toast({ title: t('create_failed_title'), @@ -146,9 +157,30 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: } const customer = invoice.customer + const sendMode = getCreditNoteSendMode({ + customerHasEmail: !!createdCreditNote?.customer.email, + isSandbox, + canEmail, + }) + + function handleSendPromptOpenChange(open: boolean) { + setShowSendPrompt(open) + if (!open && createdCreditNote) { + router.push(`/invoices/${createdCreditNote.id}`) + } + } return (
+ {createdCreditNote && ( + undefined} + /> + )} {/* Header */}
)} {invoice.status === 'draft' && !isDeliveryNote && invoice.invoice_number && ( - customerHasEmail ? ( + preferredSendMode === 'email' ? ( ) : ( ) )} + {isCopyable && canWrite && ( + + + + )} + {creditNoteNeedsRepair && ( + + )} {isDeliveryNote && invoice.status === 'draft' && ( @@ -1201,7 +1272,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st )} {/* Status actions */} - {invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && ( + {invoice.status !== 'cancelled' && invoice.status !== 'credited' && (!invoice.credited_invoice_id || invoice.status === 'draft') && ( {t('actions_card_title')} @@ -1239,7 +1310,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {/* When the customer has an email the header offers "Send via email" as the primary; keep the manual-mark-sent path here as the secondary alternative (it is not in the header). */} - {!isDeliveryNote && customerHasEmail && ( + {!isDeliveryNote && preferredSendMode === 'email' && ( <>

{t('send_manual_hint_with_email')} @@ -1261,12 +1332,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st disabled={isDeleting} > - {t('delete_draft')} + {t(isCreditNote ? 'remove_credit_draft' : 'delete_draft')} ) )} - {((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice) && ( + {((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice && !creditNote) && (

- {/* Remove/cancel confirmation. A numbered draft is makulerad (status flips - to 'cancelled', number retained for a gap-free series); an unnumbered - draft is hard deleted since it never entered the number series. */} + {/* Remove/cancel confirmation. An unissued credit-note draft and an + unnumbered invoice draft are hard deleted; other numbered drafts are + retained as cancelled to preserve their number series. */} - {invoice.invoice_number ? t('delete_dialog_title') : t('remove_dialog_title')} + + {isCreditNote + ? t('remove_credit_dialog_title') + : invoice.invoice_number + ? t('delete_dialog_title') + : t('remove_dialog_title')} + - {invoice.invoice_number ? ( + {isCreditNote ? ( + t('remove_credit_dialog_desc') + ) : invoice.invoice_number ? ( <> {t('delete_dialog_desc_with_number_1')} {t('delete_dialog_status_makulerad')} @@ -1310,7 +1389,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 2ea2355c..ef93c73a 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -87,7 +87,8 @@ export default function InvoicesPage() { // /invoices/new redirect) opens the same dialog, and the browser back // button closes it. No canWrite gate here: like the old /invoices/new page, // the editor itself disables submission for viewers. - const showNewInvoice = searchParams.has('new') + const copyFromId = searchParams.get('copy') + const showNewInvoice = searchParams.has('new') || copyFromId !== null const closeNewInvoice = () => router.replace('/invoices', { scroll: false }) const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false }) @@ -377,6 +378,7 @@ export default function InvoicesPage() { { if (!open) closeNewInvoice() }} diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 6e9bfd86..88f73160 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -13,7 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus, CalendarClock } from 'lucide-react' +import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus, CalendarClock, Paperclip } from 'lucide-react' import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker' import { useCanWrite } from '@/lib/hooks/use-can-write' @@ -22,6 +22,7 @@ import Link from 'next/link' import { AccountNumber } from '@/components/ui/account-number' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import { DocumentViewButton } from '@/components/bookkeeping/DocumentViewButton' import { formatAmount, formatCurrency } from '@/lib/utils' import { getDisplayTotal } from '@/lib/invoices/rounding' import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types' @@ -788,6 +789,26 @@ export default function SupplierInvoiceDetailPage() { )} + {invoice.document_id && ( + + + {t('document_title')} + + +
+
+ + {t('document_attached')} +
+ +
+
+
+ )} + {/* Journal entries (sambandskrav) */} diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx index d24d0a8e..dc4c3749 100644 --- a/app/(public)/privacy/page.tsx +++ b/app/(public)/privacy/page.tsx @@ -45,6 +45,7 @@ export default function PrivacyPolicyPage() {
  • Kontouppgifter: E-postadress (för inloggning)
  • Företagsuppgifter: Företagsnamn, organisationsnummer, adress, kontaktuppgifter
  • +
  • Kundidentitet: Personnummer för privatkunder när det behövs för avtal eller fakturering
  • Bokföringsdata: Verifikationer, fakturor, kvitton, transaktioner, kontoplaner
  • Bankdata: Kontosaldon och transaktioner (via PSD2-koppling)
  • Dokument: Uppladdade kvitton, fakturor och andra bokföringsunderlag
  • @@ -203,6 +204,10 @@ export default function PrivacyPolicyPage() { Kontouppgifter: Så länge kontot är aktivt, plus 30 dagar efter begäran om radering (för att hantera pågående bokföringsplikter). +
  • + Kundidentitet: Under kundrelationen, eller i sju år när uppgiften + ingår i räkenskapsinformation som måste bevaras. +
  • Tekniska loggar: Maximalt 90 dagar.
  • diff --git a/app/api/articles/[id]/route.ts b/app/api/articles/[id]/route.ts index d0807510..4d4397eb 100644 --- a/app/api/articles/[id]/route.ts +++ b/app/api/articles/[id]/route.ts @@ -111,10 +111,9 @@ export const PATCH = withRouteContext( { requireWrite: true }, ) -// DELETE soft-deactivates (active = false) rather than hard-deleting. Articles -// are master data referenced by historical invoice lines via a (frozen) copy; -// keeping the row preserves the register's audit trail and the article number. -// Re-activate by PATCHing { active: true }. +// Articles are master data, while invoice lines hold frozen copies of the +// accounting values. An article may therefore be deleted only while no invoice +// line references it. The preflight also covers draft invoices. export const DELETE = withRouteContext( 'article.delete', async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { @@ -122,28 +121,66 @@ export const DELETE = withRouteContext( const { user, supabase, companyId, log, requestId } = ctx const opLog = log.child({ articleId: id }) - const { data, error } = await supabase + const { error: articleError } = await supabase .from('articles') - .update({ active: false }) + .select('id') .eq('id', id) .eq('company_id', companyId) - .select() .single() - if (error) { - if (error.code === 'PGRST116') { + if (articleError) { + if (articleError.code === 'PGRST116') { return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId }) } - opLog.error('article deactivate failed', error) - return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, { + opLog.error('article lookup before delete failed', articleError) + return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, { requestId, - details: { reason: error.message }, + details: { reason: articleError.message }, }) } + const { count: usageCount, error: usageError } = await supabase + .from('invoice_items') + .select('id', { count: 'exact', head: true }) + .eq('article_id', id) + .eq('company_id', companyId) + + if (usageError) { + opLog.error('article usage check failed', usageError) + return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, { + requestId, + details: { reason: usageError.message }, + }) + } + + if ((usageCount ?? 0) > 0) { + return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId }) + } + + const { error: deleteError, count: deletedCount } = await supabase + .from('articles') + .delete({ count: 'exact' }) + .eq('id', id) + .eq('company_id', companyId) + + if (deleteError) { + if (deleteError.code === '23503') { + return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId }) + } + opLog.error('article delete failed', deleteError) + return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, { + requestId, + details: { reason: deleteError.message }, + }) + } + + if (deletedCount === 0) { + return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId }) + } + await eventBus.emit({ - type: 'article.updated', - payload: { article: data as Article, companyId: companyId!, userId: user.id }, + type: 'article.deleted', + payload: { articleId: id, companyId, userId: user.id }, }) return NextResponse.json({ success: true }) diff --git a/app/api/articles/__tests__/id.test.ts b/app/api/articles/__tests__/id.test.ts index ced0ad36..101b8f6a 100644 --- a/app/api/articles/__tests__/id.test.ts +++ b/app/api/articles/__tests__/id.test.ts @@ -1,11 +1,13 @@ /** * Tests for GET/PATCH/DELETE /api/articles/[id] (artikelregister). * - * DELETE soft-deactivates (active = false) rather than hard-deleting, so the - * article and its number survive for history. PATCH is a sparse update. + * DELETE permanently removes only articles that have never been used on an + * invoice line. PATCH is a sparse update. */ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers' +import { eventBus } from '@/lib/events' const { supabase, enqueue, reset } = createQueuedMockSupabase() @@ -97,13 +99,69 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => { expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID') }) - it('DELETE soft-deactivates and returns success', async () => { - enqueue({ data: { id: 'a1', active: false } }) + it('DELETE returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) - const response = await DELETE(createMockRequest('/api/articles/a1', { method: 'DELETE' }), createMockRouteParams({ id: 'a1' })) + const response = await DELETE( + createMockRequest('/api/articles/a1', { method: 'DELETE' }), + createMockRouteParams({ id: 'a1' }), + ) + + expect(response.status).toBe(401) + }) + + it('DELETE returns 404 when the article is not found', async () => { + enqueue({ data: null, error: { code: 'PGRST116', message: 'not found' } }) + + const response = await DELETE( + createMockRequest('/api/articles/a1', { method: 'DELETE' }), + createMockRouteParams({ id: 'a1' }), + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('ARTICLE_NOT_FOUND') + }) + + it('DELETE rejects an article used on an invoice line', async () => { + enqueue({ data: { id: 'a1' }, error: null }) + enqueue({ data: null, error: null, count: 1 }) + + const response = await DELETE( + createMockRequest('/api/articles/a1', { method: 'DELETE' }), + createMockRouteParams({ id: 'a1' }), + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('ARTICLE_IN_USE') + expect(supabase.from).toHaveBeenCalledTimes(2) + }) + + it('DELETE permanently removes an unused article', async () => { + enqueue({ data: { id: 'a1' }, error: null }) + enqueue({ data: null, error: null, count: 0 }) + enqueue({ data: null, error: null, count: 1 }) + + const emitSpy = vi.spyOn(eventBus, 'emit') + const response = await DELETE( + createMockRequest('/api/articles/a1', { method: 'DELETE' }), + createMockRouteParams({ id: 'a1' }), + ) const { status, body } = await parseJsonResponse<{ success: boolean }>(response) expect(status).toBe(200) expect(body.success).toBe(true) + expect(supabase.from).toHaveBeenNthCalledWith(1, 'articles') + expect(supabase.from).toHaveBeenNthCalledWith(2, 'invoice_items') + expect(supabase.from).toHaveBeenNthCalledWith(3, 'articles') + expect(emitSpy).toHaveBeenCalledWith({ + type: 'article.deleted', + payload: { articleId: 'a1', companyId: 'company-1', userId: 'user-1' }, + }) }) }) diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 0964ef67..81359277 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -4,6 +4,7 @@ import { UpdateCustomerSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number' export const GET = withRouteContext( 'customer.get', @@ -37,7 +38,7 @@ export const GET = withRouteContext( .eq('company_id', companyId) .order('invoice_date', { ascending: false }) - return NextResponse.json({ data: { ...data, invoices: invoices || [] } }) + return NextResponse.json({ data: { ...maskCustomerRow(data), invoices: invoices || [] } }) }, ) @@ -55,6 +56,26 @@ export const PATCH = withRouteContext( if (!result.success) return result.response const body = result.data + const { data: existing, error: existingError } = await supabase + .from('customers') + .select('id, customer_type') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (existingError || !existing) { + if (existingError?.code === 'PGRST116') { + return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId }) + } + opLog.error('customer lookup before update failed', existingError) + return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId }) + } + + const effectiveType = body.customer_type ?? existing.customer_type + if (body.personal_number && effectiveType !== 'individual') { + return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId }) + } + const updateData: Record = {} if (body.name !== undefined) updateData.name = body.name if (body.customer_type !== undefined) updateData.customer_type = body.customer_type @@ -69,6 +90,11 @@ export const PATCH = withRouteContext( if (body.country !== undefined) updateData.country = body.country if (body.org_number !== undefined) updateData.org_number = body.org_number if (body.vat_number !== undefined) updateData.vat_number = body.vat_number + if (body.personal_number !== undefined) { + updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number) + } else if (body.customer_type !== undefined && effectiveType !== 'individual') { + updateData.personal_number = null + } if (body.language !== undefined) updateData.language = body.language if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms if (body.notes !== undefined) updateData.notes = body.notes @@ -82,6 +108,9 @@ export const PATCH = withRouteContext( .single() if (error) { + if (error.code === 'PGRST116') { + return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId }) + } if (error.code === '23505') { return errorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', opLog, { requestId, @@ -127,7 +156,7 @@ export const PATCH = withRouteContext( } } - return NextResponse.json({ data }) + return NextResponse.json({ data: maskCustomerRow(data) }) }, { requireWrite: true }, ) diff --git a/app/api/customers/__tests__/personal-number.test.ts b/app/api/customers/__tests__/personal-number.test.ts new file mode 100644 index 00000000..67629894 --- /dev/null +++ b/app/api/customers/__tests__/personal-number.test.ts @@ -0,0 +1,228 @@ +import { NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { eventBus } from '@/lib/events' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' +import { decryptPersonnummer } from '@/lib/salary/personnummer' + +const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] } +let queryResult: { data: unknown; error: unknown } = { data: null, error: null } + +const buildChain = (): unknown => + new Proxy( + {}, + { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (value: unknown) => void) => resolve(queryResult) + } + return (...args: unknown[]) => { + if (prop === 'insert') captured.insert.push(args[0]) + if (prop === 'update') captured.update.push(args[0]) + return buildChain() + } + }, + }, + ) + +const supabase = { + from: vi.fn(() => buildChain()), + rpc: vi.fn(() => buildChain()), +} + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' +import { PATCH } from '../[id]/route' + +type CustomerWrite = { personal_number?: string | null } + +describe('personal_number on customer routes', () => { + const routeParams = { params: Promise.resolve({ id: 'customer-1' }) } + + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + captured.insert.length = 0 + captured.update.length = 0 + queryResult = { data: null, error: null } + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 before creating a customer when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST( + createMockRequest('/api/customers', { + method: 'POST', + body: { name: 'Anna Andersson', customer_type: 'individual' }, + }), + { params: Promise.resolve({}) }, + ) + + expect(response.status).toBe(401) + expect(captured.insert).toHaveLength(0) + }) + + it('returns 400 for an invalid personal number', async () => { + const response = await POST( + createMockRequest('/api/customers', { + method: 'POST', + body: { + name: 'Anna Andersson', + customer_type: 'individual', + personal_number: 'not-a-personal-number', + }, + }), + { params: Promise.resolve({}) }, + ) + + expect(response.status).toBe(400) + expect(captured.insert).toHaveLength(0) + }) + + it('stores the personal number when creating a private customer', async () => { + queryResult = { + data: { + id: 'customer-1', + name: 'Anna Andersson', + customer_type: 'individual', + personal_number: '19900101-1234', + }, + error: null, + } + + const response = await POST( + createMockRequest('/api/customers', { + method: 'POST', + body: { + name: 'Anna Andersson', + customer_type: 'individual', + personal_number: '19900101-1234', + }, + }), + { params: Promise.resolve({}) }, + ) + + const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response) + expect(status).toBe(200) + const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string + expect(encrypted).not.toBe('19900101-1234') + expect(decryptPersonnummer(encrypted)).toBe('19900101-1234') + expect(body.data.personal_number).toBe('********-1234') + }) + + it('updates the personal number for an existing private customer', async () => { + queryResult = { + data: { + id: 'customer-1', + customer_type: 'individual', + personal_number: '900101-1234', + }, + error: null, + } + + const response = await PATCH( + createMockRequest('/api/customers/customer-1', { + method: 'PATCH', + body: { personal_number: '900101-1234' }, + }), + routeParams, + ) + + expect(response.status).toBe(200) + const encrypted = (captured.update[0] as CustomerWrite).personal_number as string + expect(encrypted).not.toBe('900101-1234') + expect(decryptPersonnummer(encrypted)).toBe('900101-1234') + }) + + it('clears the personal number when null is sent', async () => { + queryResult = { + data: { id: 'customer-1', customer_type: 'individual', personal_number: null }, + error: null, + } + + const response = await PATCH( + createMockRequest('/api/customers/customer-1', { + method: 'PATCH', + body: { personal_number: null }, + }), + routeParams, + ) + + expect(response.status).toBe(200) + expect((captured.update[0] as CustomerWrite).personal_number).toBeNull() + }) + + it('does not change the personal number when the field is omitted', async () => { + queryResult = { + data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' }, + error: null, + } + + const response = await PATCH( + createMockRequest('/api/customers/customer-1', { + method: 'PATCH', + body: { name: 'Anna A' }, + }), + routeParams, + ) + + expect(response.status).toBe(200) + expect(captured.update[0]).not.toHaveProperty('personal_number') + }) + + it('rejects a personal number for a corporate customer', async () => { + queryResult = { + data: { id: 'customer-1', customer_type: 'swedish_business' }, + error: null, + } + + const response = await PATCH( + createMockRequest('/api/customers/customer-1', { + method: 'PATCH', + body: { personal_number: '900101-1234' }, + }), + routeParams, + ) + + const { body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(response.status).toBe(400) + expect(body.error.code).toBe('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED') + expect(captured.update).toHaveLength(0) + }) + + it('returns 404 when the customer does not exist', async () => { + queryResult = { + data: null, + error: { code: 'PGRST116', message: 'No rows returned' }, + } + + const response = await PATCH( + createMockRequest('/api/customers/missing', { + method: 'PATCH', + body: { personal_number: '900101-1234' }, + }), + { params: Promise.resolve({ id: 'missing' }) }, + ) + + expect(response.status).toBe(404) + }) +}) diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index 10bb84c3..87b0e8b8 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -7,6 +7,7 @@ import { validateVatNumber } from '@/lib/vat/vies-client' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { Customer } from '@/types' +import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number' ensureInitialized() @@ -26,7 +27,7 @@ export const GET = withRouteContext( return errorResponse(error, log, { requestId }) } - return NextResponse.json({ data }) + return NextResponse.json({ data: (data ?? []).map(maskCustomerRow) }) }, ) @@ -59,6 +60,7 @@ export const POST = withRouteContext( country: body.country || 'Sweden', org_number: body.org_number, vat_number: body.vat_number, + personal_number: encryptCustomerPersonalNumber(body.personal_number), language: body.language || 'sv', default_payment_terms: body.default_payment_terms || 30, notes: body.notes, @@ -104,12 +106,13 @@ export const POST = withRouteContext( } } + const safeCustomer = maskCustomerRow(data) await eventBus.emit({ type: 'customer.created', - payload: { customer: data as Customer, companyId: companyId!, userId: user.id }, + payload: { customer: safeCustomer as Customer, companyId: companyId!, userId: user.id }, }) - return NextResponse.json({ data }) + return NextResponse.json({ data: safeCustomer }) }, { requireWrite: true }, ) diff --git a/app/api/invoices/[id]/__tests__/route.test.ts b/app/api/invoices/[id]/__tests__/route.test.ts index d35294ae..af31c0a6 100644 --- a/app/api/invoices/[id]/__tests__/route.test.ts +++ b/app/api/invoices/[id]/__tests__/route.test.ts @@ -18,13 +18,14 @@ vi.mock('@/lib/init', () => ({ vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) -import { DELETE } from '../route' +import { DELETE, PATCH } from '../route' describe('DELETE /api/invoices/[id]', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -175,3 +176,53 @@ describe('DELETE /api/invoices/[id]', () => { expect(status).toBe(500) }) }) + +describe('PATCH /api/invoices/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1', email: 'test@test.se' } }, + }) + }) + + it('rejects editing a credit-note draft', async () => { + enqueue({ + data: { + id: 'credit-1', + status: 'draft', + invoice_number: 'KR-F-2026001', + journal_entry_id: null, + is_self_billed: false, + credited_invoice_id: '11111111-1111-4111-8111-111111111111', + }, + error: null, + }) + + const response = await PATCH( + createMockRequest('/api/invoices/credit-1', { + method: 'PATCH', + body: { + customer_id: '22222222-2222-4222-8222-222222222222', + invoice_date: '2026-07-14', + due_date: '2026-07-14', + currency: 'SEK', + items: [ + { + description: 'Kredit', + quantity: 1, + unit: 'st', + unit_price: 100, + }, + ], + }, + }), + createMockRouteParams({ id: 'credit-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT') + }) +}) diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index be34bcb7..0684aab4 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -114,6 +114,42 @@ describe('POST /api/invoices/[id]/mark-paid', () => { expect(status).toBe(400) }) + it('rejects a sent credit note before booking a payment', async () => { + const invoice = makeInvoice({ + status: 'sent', + credited_invoice_id: 'original-invoice-1', + }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE') + expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + + it('rejects an original invoice while an active credit-note draft exists', async () => { + const invoice = { + ...makeInvoice({ status: 'sent', credited_invoice_id: null }), + credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }], + } + enqueue({ data: invoice, error: null }) + + const response = await POST( + createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }), + createMockRouteParams({ id: 'inv-1' }), + ) + const { body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(response.status).toBe(400) + expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE') + expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + }) + it('marks sent invoice as paid with accrual method', async () => { const customer = makeCustomer() const invoice = makeInvoice({ diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index e12105e6..3604a27f 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -31,7 +31,7 @@ export const POST = withRouteContext( const { data: invoice, error: invoiceError } = await supabase .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') + .select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)') .eq('id', id) .eq('company_id', companyId) .single() @@ -40,6 +40,26 @@ export const POST = withRouteContext( return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId }) } + if (invoice.credited_invoice_id) { + return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, { + requestId, + details: { reason: 'credit_note' }, + }) + } + + const activeCreditNotes = ((invoice as { credit_notes?: Array<{ + status: string + creation_complete?: boolean + }> }).credit_notes ?? []).filter( + (creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false, + ) + if (activeCreditNotes.length > 0) { + return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, { + requestId, + details: { reason: 'active_credit_note' }, + }) + } + if (invoice.status !== 'sent' && invoice.status !== 'overdue') { return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, { requestId, diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts index 09000e56..4b86e4c3 100644 --- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -52,6 +52,17 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ mockCreateInvoiceJournalEntry(...args), })) +const mockIssueCreditNote = vi.fn() +vi.mock('@/lib/invoices/issue-credit-note', () => ({ + issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args), + creditNoteNeedsJournalEntry: (method: string, original: { status: string; journal_entry_id?: string | null; paid_at?: string | null; paid_amount?: number | null }) => + method === 'accrual' || + !!original.journal_entry_id || + original.status === 'paid' || + !!original.paid_at || + Math.abs(original.paid_amount ?? 0) > 0, +})) + const mockUploadDocument = vi.fn() vi.mock('@/lib/core/documents/document-service', () => ({ uploadDocument: (...args: unknown[]) => mockUploadDocument(...args), @@ -94,18 +105,66 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf')) mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + mockIssueCreditNote.mockResolvedValue({ + complete: true, + journalEntryId: 'credit-je-1', + journalEntryRequired: true, + failures: [], + }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + }) + + const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 404 when the invoice does not exist', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + + const request = createMockRequest('/api/invoices/missing/mark-sent', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'missing' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(404) + }) + + it('returns 400 when the invoice is not a draft', async () => { + enqueue({ data: makeInvoice({ id: 'inv-1', status: 'sent' }), error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) }) it('archives the rendered PDF as underlag linked to the journal entry', async () => { enqueue({ data: invoice, error: null }) // fetch invoice - enqueue({ data: null, error: null }) // status update enqueue({ data: company, error: null }) // settings + enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-7' }) enqueue({ data: null, error: null }) // update invoice with journal_entry_id const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) - const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string | null + partial?: boolean + partial_failures?: Array<{ step: string }> + }>(response) expect(status).toBe(200) expect(body.success).toBe(true) @@ -128,37 +187,25 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { ) }) - it('archives the PDF even when journal entry creation fails (non-blocking)', async () => { + it('restores the draft and fails closed when journal entry creation fails', async () => { enqueue({ data: invoice, error: null }) - enqueue({ data: null, error: null }) enqueue({ data: company, error: null }) + enqueue({ data: [{ id: 'inv-1' }], error: null }) mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked')) const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) - const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) - expect(status).toBe(200) - expect(body.success).toBe(true) - expect(body.journal_entry_id).toBeNull() - - expect(mockUploadDocument).toHaveBeenCalledTimes(1) - expect(mockUploadDocument).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - 'company-1', - expect.objectContaining({ name: 'faktura-F-2026010.pdf' }), - expect.objectContaining({ - upload_source: 'system', - journal_entry_id: undefined, - }) - ) + expect(status).toBe(500) + expect(body.error.code).toBe('INVOICE_MARK_SENT_BOOK_FAILED') + expect(mockUploadDocument).not.toHaveBeenCalled() }) it('still returns 200 when PDF archival itself fails', async () => { enqueue({ data: invoice, error: null }) - enqueue({ data: null, error: null }) enqueue({ data: company, error: null }) + enqueue({ data: [{ id: 'inv-1' }], error: null }) mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-8' }) enqueue({ data: null, error: null }) @@ -183,8 +230,8 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { }) enqueue({ data: proforma, error: null }) - enqueue({ data: null, error: null }) enqueue({ data: company, error: null }) + enqueue({ data: [{ id: 'inv-2' }], error: null }) const request = createMockRequest('/api/invoices/inv-2/mark-sent', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'inv-2' })) @@ -196,10 +243,10 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { expect(mockRenderToBuffer).not.toHaveBeenCalled() }) - it('uses kreditfaktura filename when archiving a credit note', async () => { + it('issues the credit note and uses a credit-note filename when archiving it', async () => { const creditNote = makeInvoice({ id: 'inv-3', - invoice_number: 'F-2026011', + invoice_number: 'KR-F-2026010', status: 'draft', credited_invoice_id: 'inv-1', customer, @@ -207,31 +254,132 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { }) enqueue({ data: creditNote, error: null }) - enqueue({ data: null, error: null }) enqueue({ data: company, error: null }) - mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-9' }) - enqueue({ data: null, error: null }) - // Lookup of the original invoice's number for the credit note PDF - enqueue({ data: { invoice_number: 'F-2026010' }, error: null }) + const original = { + id: 'inv-1', + invoice_number: 'F-2026010', + status: 'sent', + journal_entry_id: 'original-je-1', + paid_at: null, + paid_amount: null, + total: 12500, + } + enqueue({ data: original, error: null }) + enqueue({ data: [{ id: 'inv-3' }], error: null }) const request = createMockRequest('/api/invoices/inv-3/mark-sent', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'inv-3' })) const { status } = await parseJsonResponse(response) expect(status).toBe(200) + expect(mockIssueCreditNote).toHaveBeenCalledWith( + expect.objectContaining({ + creditNote: expect.objectContaining({ id: 'inv-3' }), + originalInvoice: original, + accountingMethod: 'accrual', + }), + ) + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() expect(mockUploadDocument).toHaveBeenCalledWith( expect.anything(), 'user-1', 'company-1', - expect.objectContaining({ name: 'kreditfaktura-F-2026011.pdf' }), + expect.objectContaining({ name: 'kreditfaktura-KR-F-2026010.pdf' }), expect.anything() ) }) + it('fails closed and restores the draft when credit-note booking cannot start', async () => { + const creditNote = makeInvoice({ + id: 'credit-1', + invoice_number: 'KR-F-2026010', + status: 'draft', + credited_invoice_id: 'inv-1', + customer, + items: invoice.items, + }) + enqueue({ data: creditNote, error: null }) + enqueue({ data: company, error: null }) + enqueue({ + data: { + id: 'inv-1', + invoice_number: 'F-2026010', + status: 'sent', + journal_entry_id: 'original-je-1', + paid_at: null, + paid_amount: null, + total: 12500, + }, + error: null, + }) + enqueue({ data: [{ id: 'credit-1' }], error: null }) + mockIssueCreditNote.mockResolvedValue({ + complete: false, + journalEntryId: null, + journalEntryRequired: true, + failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }], + }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'credit-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(500) + expect(mockUploadDocument).not.toHaveBeenCalled() + }) + + it('repairs a sent credit note without running the draft status transition again', async () => { + const creditNote = makeInvoice({ + id: 'credit-1', + invoice_number: 'KR-F-2026010', + status: 'sent', + credited_invoice_id: 'inv-1', + journal_entry_id: null, + customer, + items: invoice.items, + }) + enqueue({ data: creditNote, error: null }) + enqueue({ data: company, error: null }) + enqueue({ + data: { + id: 'inv-1', + invoice_number: 'F-2026010', + status: 'sent', + journal_entry_id: 'original-je-1', + paid_at: null, + paid_amount: null, + total: 12500, + }, + error: null, + }) + + const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'credit-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockIssueCreditNote).toHaveBeenCalledOnce() + }) + + it('returns 409 when another request already marked the draft as sent', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(409) + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + expect(mockUploadDocument).not.toHaveBeenCalled() + }) + it('renders the archived PDF as if already sent (no UTKAST banner)', async () => { enqueue({ data: invoice, error: null }) // fetch invoice (status: 'draft') - enqueue({ data: null, error: null }) // status update enqueue({ data: company, error: null }) // settings + enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-99' }) enqueue({ data: null, error: null }) // update invoice with journal_entry_id diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index b01b7a0c..de4ade89 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -2,13 +2,28 @@ import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices' +import { eventBus } from '@/lib/events' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' +import { + creditNoteNeedsJournalEntry, + issueCreditNote, + type CreditNoteOriginalInvoice, +} from '@/lib/invoices/issue-credit-note' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { uploadDocument } from '@/lib/core/documents/document-service' -import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { + AccountingMethod, + CompanySettings, + CreditNote, + Customer, + EntityType, + Invoice, + InvoiceItem, +} from '@/types' ensureInitialized() @@ -21,7 +36,7 @@ ensureInitialized() */ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'invoice.mark_sent', - async (request, { supabase, user, companyId, log }, { params }) => { + async (_request, { supabase, user, companyId, log, requestId }, { params }) => { const { id } = await params // Fetch invoice @@ -33,14 +48,16 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( .single() if (invoiceError || !invoice) { - return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 }) + return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId }) } - if (invoice.status !== 'draft') { - return NextResponse.json( - { error: 'Endast utkast kan markeras som skickade' }, - { status: 400 } - ) + const isCreditNote = !!invoice.credited_invoice_id + + if (!isCreditNote && invoice.status !== 'draft') { + return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId }) + } + if (isCreditNote && !['draft', 'sent'].includes(invoice.status)) { + return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId }) } // Assign invoice number now if this draft doesn't have one yet @@ -48,43 +65,126 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) } catch (err) { log.error('failed to assign invoice number on mark-sent', err as Error) - return NextResponse.json( - { error: 'Kunde inte tilldela fakturanummer. Försök igen.' }, - { status: 500 } - ) - } - - // Update status to sent - const { error: updateError } = await supabase - .from('invoices') - .update({ status: 'sent' }) - .eq('id', id) - .eq('company_id', companyId) - - if (updateError) { - return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 }) + return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId }) } // Fetch full company settings for PDF rendering and accounting method - const { data: settings } = await supabase + const { data: settings, error: settingsError } = await supabase .from('company_settings') .select('*') .eq('company_id', companyId) .single() - const accountingMethod = settings?.accounting_method || 'accrual' + if (settingsError || !settings) { + return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId }) + } + + const accountingMethod = (settings.accounting_method || 'accrual') as AccountingMethod + const entityType = (settings.entity_type as EntityType) || 'enskild_firma' + let originalInvoice: CreditNoteOriginalInvoice | undefined + let originalInvoiceNumber: string | undefined + + if (invoice.credited_invoice_id) { + const { data: original } = await supabase + .from('invoices') + .select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total') + .eq('id', invoice.credited_invoice_id) + .eq('company_id', companyId) + .single() + + if (!original) { + return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', log, { requestId }) + } + + originalInvoice = original as CreditNoteOriginalInvoice + originalInvoiceNumber = original.invoice_number ?? undefined + } + + const journalEntryRequired = originalInvoice + ? creditNoteNeedsJournalEntry(accountingMethod, originalInvoice) + : false + const isRecovery = isCreditNote && invoice.status === 'sent' + + if ( + isRecovery && + originalInvoice?.status === 'credited' && + (!journalEntryRequired || !!invoice.journal_entry_id) + ) { + return errorResponseFromCode('INVOICE_CREDIT_ALREADY_ISSUED', log, { requestId }) + } + + // Compare-and-set prevents two concurrent requests from posting two journal + // entries for the same draft. + let statusFlipped = false + if (!isRecovery) { + const { data: updatedRows, error: updateError } = await supabase + .from('invoices') + .update({ status: 'sent' }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'draft') + .select('id') + + if (updateError) { + log.error('invoice mark-sent status update failed', updateError) + return errorResponseFromCode('INVOICE_MARK_SENT_STATUS_FAILED', log, { requestId }) + } + if (!updatedRows || updatedRows.length === 0) { + return errorResponseFromCode('INVOICE_MARK_SENT_RACE', log, { requestId }) + } + statusFlipped = true + } // Only create journal entries for real invoices (not proformas or delivery notes) const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' let journalEntryId: string | null = null - if (isRealInvoice && accountingMethod === 'accrual') { + const partialFailures: Array<{ step: string; reason: string }> = [] + + if (isCreditNote && originalInvoice) { + const issueResult = await issueCreditNote({ + supabase, + companyId, + userId: user.id, + creditNote: invoice as CreditNote, + originalInvoice, + entityType, + accountingMethod, + log, + }) + journalEntryId = issueResult.journalEntryId + partialFailures.push(...issueResult.failures) + + if (!issueResult.complete) { + // If no immutable entry was created, restoring the draft is safe and + // lets the user fix the period/account issue before trying again. + if (statusFlipped && issueResult.journalEntryRequired && !issueResult.journalEntryId) { + await supabase + .from('invoices') + .update({ status: 'draft' }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'sent') + .is('journal_entry_id', null) + } + return errorResponseFromCode( + issueResult.repairRequired + ? 'INVOICE_CREDIT_REPAIR_REQUIRED' + : 'INVOICE_CREDIT_ISSUE_INCOMPLETE', + log, + { + requestId, + details: { failure_steps: issueResult.failures.map((failure) => failure.step) }, + }, + ) + } + } else if (isRealInvoice && accountingMethod === 'accrual') { try { const journalEntry = await createInvoiceJournalEntry( supabase, companyId, user.id, invoice as Invoice, - (settings?.entity_type as EntityType) || 'enskild_firma', + entityType, invoice.customer?.name ) if (journalEntry) { @@ -100,12 +200,16 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( invoice as Invoice, (invoice.items as InvoiceItem[] | null) ?? [], journalEntry.id, - (settings?.entity_type as EntityType) || 'enskild_firma', + entityType, ) if (accrual.failed > 0) { log.error('accrual schedule creation failed on mark-sent', { failed: accrual.failed, }) + partialFailures.push({ + step: 'accrual_schedules', + reason: `${accrual.failed} periodisering(ar) kunde inte skapas`, + }) } const { error: linkError } = await supabase @@ -122,32 +226,55 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( log.error('mark-sent: journal_entry_id link to invoice failed', linkError, { journalEntryId: journalEntry.id, }) + partialFailures.push({ + step: 'journal_link', + reason: 'Verifikatet skapades men kunde inte kopplas till fakturan.', + }) } + } else { + partialFailures.push({ + step: 'journal_entry', + reason: 'Ingen öppen bokföringsperiod hittades för fakturans datum.', + }) } } catch (err) { log.error('failed to create invoice journal entry on mark-sent', err as Error) + partialFailures.push({ + step: 'journal_entry', + reason: 'Fakturans verifikat kunde inte skapas.', + }) } } + if (isRealInvoice && accountingMethod === 'accrual' && !isCreditNote && !journalEntryId) { + if (statusFlipped) { + const { error: rollbackError } = await supabase + .from('invoices') + .update({ status: 'draft' }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'sent') + .is('journal_entry_id', null) + if (rollbackError) log.error('failed to restore draft after mark-sent booking failure', rollbackError) + } + return errorResponseFromCode('INVOICE_MARK_SENT_BOOK_FAILED', log, { requestId }) + } + + if (partialFailures.some((failure) => failure.step === 'journal_link')) { + return errorResponseFromCode('INVOICE_MARK_SENT_REPAIR_REQUIRED', log, { + requestId, + details: { failure_steps: ['journal_link'] }, + }) + } + // Render and archive the PDF as underlag so it remains retrievable even if // the invoice row is later cancelled. Mirrors the send route. - if (isRealInvoice && settings) { + if (isRealInvoice) { try { const items = (invoice.items as InvoiceItem[] | null ?? []).slice().sort( (a, b) => a.sort_order - b.sort_order ) - let originalInvoiceNumber: string | undefined - if (invoice.credited_invoice_id) { - const { data: originalInvoice } = await supabase - .from('invoices') - .select('invoice_number') - .eq('id', invoice.credited_invoice_id) - .eq('company_id', companyId) - .single() - originalInvoiceNumber = originalInvoice?.invoice_number ?? undefined - } - // The DB status flip already happened above, but the in-memory `invoice` // is stale and still reads 'draft': override here so the archived // underlag isn't stamped "UTKAST: inte en giltig faktura". @@ -183,13 +310,27 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( }) } catch (err) { log.error('failed to archive invoice PDF on mark-sent', err as Error) + partialFailures.push({ + step: 'pdf_archive', + reason: 'Fakturans PDF kunde inte arkiveras.', + }) } } + if (!isCreditNote) { + await eventBus.emit({ + type: 'invoice.sent', + payload: { invoice: { ...(invoice as Invoice), status: 'sent' }, companyId, userId: user.id }, + }) + } + return NextResponse.json({ success: true, status: 'sent', journal_entry_id: journalEntryId, + ...(partialFailures.length > 0 + ? { partial: true, partial_failures: partialFailures } + : {}), }) }, { requireWrite: true }, diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index e278fd5e..a2fd3b1b 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -1,11 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -import { createLogger } from '@/lib/logger' import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { UpdateInvoiceSchema } from '@/lib/api/schemas' @@ -15,8 +11,6 @@ import type { InvoiceDocumentType } from '@/types' ensureInitialized() // Module-level: wires the audit-log handler for invoice.draft_deleted. -const log = createLogger('api.invoices.cancel') - /** * DELETE /api/invoices/[id] * @@ -32,37 +26,25 @@ const log = createLogger('api.invoices.cancel') * Only drafts may be removed either way. Sent / paid invoices are immutable per * BFL and must be reversed via a credit note instead. */ -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.delete', + async (_request, { user, supabase, companyId, log, requestId }, { params }) => { + const { id } = await params + const opLog = log.child({ invoiceId: id }) const { data: invoice, error: fetchError } = await supabase .from('invoices') - .select('id, status, invoice_number, user_id') + .select('id, status, invoice_number, user_id, credited_invoice_id, journal_entry_id') .eq('id', id) .eq('company_id', companyId) .single() if (fetchError || !invoice) { - return NextResponse.json({ error: 'Invoice not found' }, { status: 404 }) + return errorResponseFromCode('INVOICE_NOT_FOUND', opLog, { requestId }) } if (invoice.status !== 'draft') { - return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log) + return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', opLog, { requestId }) } // Unnumbered drafts (saved via "Spara som utkast", never finalized) are not @@ -82,13 +64,14 @@ export async function DELETE( .select('id') if (removeError) { - return NextResponse.json({ error: removeError.message }, { status: 500 }) + opLog.error('invoice draft delete failed', removeError) + return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId }) } if (!removed || removed.length === 0) { // Finalized between fetch and delete: refuse rather than fall through to // makulering of a now-issued invoice. - return errorResponseFromCode('INVOICE_CANCEL_RACE', log) + return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId }) } // The row is gone, so there's no journal trace of the removal. Emit an @@ -100,7 +83,7 @@ export async function DELETE( payload: { invoiceId: id, companyId, userId: user.id }, }) - return NextResponse.json({ data: { deleted: true } }) + return NextResponse.json({ data: { deleted: true } }) } // Numbered draft: retain the row and its number, flip to 'cancelled' @@ -118,15 +101,18 @@ export async function DELETE( .select('id') if (cancelError) { - return NextResponse.json({ error: cancelError.message }, { status: 500 }) + opLog.error('invoice cancellation failed', cancelError) + return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId }) } if (!updated || updated.length === 0) { - return errorResponseFromCode('INVOICE_CANCEL_RACE', log) + return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId }) } - return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } }) -} + return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } }) + }, + { requireWrite: true }, +) /** * PATCH /api/invoices/[id] @@ -161,7 +147,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( // received self-billing document) may be edited. const { data: existing, error: fetchError } = await supabase .from('invoices') - .select('id, status, invoice_number, journal_entry_id, is_self_billed') + .select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id') .eq('id', id) .eq('company_id', companyId!) .single() diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 97b964ab..380d228f 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -66,6 +66,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ mockCreateInvoiceJournalEntry(...args), })) +const mockIssueCreditNote = vi.fn() +vi.mock('@/lib/invoices/issue-credit-note', () => ({ + issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args), +})) + // The sandbox guard issues a company_settings query at the top of the route; // short-circuit it in tests since the queued mock-supabase is shaped for the // route's existing fetch chain, not an extra pre-flight read. @@ -113,6 +118,12 @@ describe('POST /api/invoices/[id]/send', () => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) mockIsConfigured.mockReturnValue(true) mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf')) + mockIssueCreditNote.mockResolvedValue({ + complete: true, + journalEntryId: 'credit-je-1', + journalEntryRequired: true, + failures: [], + }) }) it('returns 401 when not authenticated', async () => { @@ -314,6 +325,144 @@ describe('POST /api/invoices/[id]/send', () => { ) }) + it('issues and books a credit-note draft through the email send flow', async () => { + const creditNote = makeInvoice({ + id: 'credit-1', + invoice_number: 'KR-F-2024001', + status: 'draft', + credited_invoice_id: 'inv-1', + customer, + items: (invoice.items ?? []).map((item) => ({ + ...item, + invoice_id: 'credit-1', + quantity: -Math.abs(item.quantity), + line_total: -Math.abs(item.line_total), + vat_amount: -Math.abs(item.vat_amount ?? 0), + })), + subtotal: -10000, + vat_amount: -2500, + total: -12500, + }) + const original = { + id: 'inv-1', + invoice_number: 'F-2024001', + status: 'sent', + journal_entry_id: 'original-je-1', + paid_at: null, + paid_amount: null, + total: 12500, + } + + enqueue({ data: creditNote, error: null }) + enqueue({ data: company, error: null }) + enqueue({ data: original, error: null }) + mockSendEmail.mockResolvedValue({ success: true, messageId: 'credit-message-1' }) + enqueue({ data: [{ id: 'credit-1' }], error: null }) + const emitSpy = vi.spyOn(eventBus, 'emit') + + const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'credit-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean; message: string }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.message).toContain('Kreditfakturan har skickats') + expect(mockIssueCreditNote).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: 'company-1', + creditNote: expect.objectContaining({ id: 'credit-1' }), + originalInvoice: original, + accountingMethod: 'accrual', + }), + ) + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: [ + expect.objectContaining({ filename: 'kreditfaktura-KR-F-2024001.pdf' }), + ], + }), + ) + expect(emitSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'invoice.sent' }), + ) + }) + + it('does not email a credit note when its bookkeeping cannot be completed', async () => { + const creditNote = makeInvoice({ + id: 'credit-1', + invoice_number: 'KR-F-2024001', + status: 'draft', + credited_invoice_id: 'inv-1', + customer, + items: invoice.items, + }) + enqueue({ data: creditNote, error: null }) + enqueue({ data: company, error: null }) + enqueue({ + data: { + id: 'inv-1', + invoice_number: 'F-2024001', + status: 'sent', + journal_entry_id: 'original-je-1', + paid_at: null, + paid_amount: null, + total: 12500, + }, + error: null, + }) + enqueue({ data: [{ id: 'credit-1' }], error: null }) + mockIssueCreditNote.mockResolvedValue({ + complete: false, + journalEntryId: null, + journalEntryRequired: true, + failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }], + }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'credit-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(500) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('retries delivery for an already-issued credit note after provider failure', async () => { + const creditNote = makeInvoice({ + id: 'credit-1', + invoice_number: 'KR-F-2024001', + status: 'sent', + credited_invoice_id: 'inv-1', + customer, + items: invoice.items, + }) + enqueue({ data: creditNote, error: null }) + enqueue({ data: company, error: null }) + enqueue({ + data: { + id: 'inv-1', + invoice_number: 'F-2024001', + status: 'credited', + journal_entry_id: 'original-je-1', + paid_at: null, + paid_amount: null, + total: 12500, + }, + error: null, + }) + mockSendEmail.mockResolvedValue({ success: true, messageId: 'retry-message-1' }) + + const response = await POST( + createMockRequest('/api/invoices/credit-1/send', { method: 'POST' }), + createMockRouteParams({ id: 'credit-1' }), + ) + + expect(response.status).toBe(200) + expect(mockIssueCreditNote).toHaveBeenCalledTimes(1) + expect(mockSendEmail).toHaveBeenCalledTimes(1) + }) + it('skips journal entry for cash method', async () => { const cashCompany = makeCompanySettings({ accounting_method: 'cash' }) enqueue({ data: invoice, error: null }) @@ -451,12 +600,10 @@ describe('POST /api/invoices/[id]/send', () => { const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) const { status, body } = await parseJsonResponse<{ error: string }>(response) - // Provider errors map to 502 PROVIDER_FAILED with the provider message in details. + // Provider errors map to a safe retryable response without leaking provider text. expect(status).toBe(502) expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_PROVIDER_FAILED') - expect( - (body.error as unknown as { details?: { providerError?: string } }).details?.providerError, - ).toContain('SMTP error') + expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true) }) it('renders the final PDF as if already sent (no UTKAST banner)', async () => { diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 3d3cb2cf..2a6b4c33 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -14,13 +14,25 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices' import { uploadDocument } from '@/lib/core/documents/document-service' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' +import { + issueCreditNote, + type CreditNoteOriginalInvoice, +} from '@/lib/invoices/issue-credit-note' import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { guardSandbox } from '@/lib/sandbox/guard' import { requireCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' -import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' +import type { + AccountingMethod, + CompanySettings, + CreditNote, + Customer, + EntityType, + Invoice, + InvoiceItem, +} from '@/types' ensureInitialized() @@ -59,6 +71,9 @@ export const POST = withRouteContext( return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId }) } + const isCreditNote = !!invoice.credited_invoice_id + const isCreditDeliveryRetry = isCreditNote && invoice.status === 'sent' + // A cancelled invoice keeps its F-series number for compliance with ML 17 // kap 24§ but is not a valid faktura: sending it would deliver a // "MAKULERAD" PDF as if it were live. Checked before the generic draft @@ -73,7 +88,7 @@ export const POST = withRouteContext( // (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id // and orphaning the first entry. Mirrors the v1 route and the MCP commit // executor, which both reject non-drafts. - if (invoice.status !== 'draft') { + if (invoice.status !== 'draft' && !isCreditDeliveryRetry) { return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, { requestId, details: { currentStatus: invoice.status }, @@ -100,18 +115,22 @@ export const POST = withRouteContext( const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order) + let originalInvoice: CreditNoteOriginalInvoice | undefined let originalInvoiceNumber: string | undefined if (invoice.credited_invoice_id) { - const { data: originalInvoice } = await supabase + const { data: original } = await supabase .from('invoices') - .select('invoice_number') + .select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total') .eq('id', invoice.credited_invoice_id) .eq('company_id', companyId) .single() - if (originalInvoice) { - originalInvoiceNumber = originalInvoice.invoice_number + if (!original) { + return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', opLog, { requestId }) } + + originalInvoice = original as CreditNoteOriginalInvoice + originalInvoiceNumber = original.invoice_number ?? undefined } // Preflight render: validate the PDF pipeline BEFORE consuming an F-series @@ -149,13 +168,15 @@ export const POST = withRouteContext( // that the number exists, so the email button and PDF QR carry it. A // failure never blocks the send: the faktura is legally valid without a // link, so it degrades to a PARTIAL warning instead. - const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice( - supabase, - companyId!, - user.id, - invoice as Invoice, - opLog, - ) + const { failure: paymentLinkFailure } = isCreditNote + ? { failure: undefined } + : await applyPaymentLinkToInvoice( + supabase, + companyId!, + user.id, + invoice as Invoice, + opLog, + ) // Final render with the assigned number: this is the buffer attached to // the email and later archived as underlag. Override status to 'sent' on @@ -182,12 +203,11 @@ export const POST = withRouteContext( ) const emailData = { - invoice: invoice as Invoice, + invoice: renderableInvoice, customer, company: company as CompanySettings, } - const isCreditNote = !!invoice.credited_invoice_id const docType = invoice.document_type || 'invoice' let filename: string if (isCreditNote) { @@ -201,6 +221,78 @@ export const POST = withRouteContext( } const ccAddress = company.email || user.email + const partialFailures: Array<{ step: string; reason: string }> = [] + if (paymentLinkFailure) { + partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure }) + } + + let statusFlipped = isCreditDeliveryRetry + let creditJournalEntryId: string | null = null + + // Credit notes must be fully issued and booked before delivery. The CAS is + // the single-winner lock; the idempotent issue service can repair any + // immutable entry that committed before a later database step failed. + if (isCreditNote && originalInvoice) { + if (!isCreditDeliveryRetry) { + const { data: flipRows, error: updateError } = await supabase + .from('invoices') + .update({ status: 'sent' }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'draft') + .select('id') + + if (updateError) { + opLog.error('credit note status update failed before issue', updateError) + return errorResponseFromCode('INVOICE_CREDIT_ISSUE_INCOMPLETE', opLog, { + requestId, + details: { failure_steps: ['status_update'] }, + }) + } + if (!flipRows || flipRows.length === 0) { + return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, { + requestId, + details: { currentStatus: 'sent' }, + }) + } + statusFlipped = true + } + + const issueResult = await issueCreditNote({ + supabase, + companyId: companyId!, + userId: user.id, + creditNote: invoice as CreditNote, + originalInvoice, + entityType: ((company as CompanySettings).entity_type as EntityType) || 'enskild_firma', + accountingMethod: ((company as Record).accounting_method || 'accrual') as AccountingMethod, + log: opLog, + }) + creditJournalEntryId = issueResult.journalEntryId + + if (!issueResult.complete) { + if (issueResult.journalEntryRequired && !issueResult.journalEntryId) { + await supabase + .from('invoices') + .update({ status: 'draft' }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'sent') + .is('journal_entry_id', null) + } + return errorResponseFromCode( + issueResult.repairRequired + ? 'INVOICE_CREDIT_REPAIR_REQUIRED' + : 'INVOICE_CREDIT_ISSUE_INCOMPLETE', + opLog, + { + requestId, + details: { failure_steps: issueResult.failures.map((failure) => failure.step) }, + }, + ) + } + } + const result = await emailService.sendEmail({ to: customer.email, cc: ccAddress, @@ -222,7 +314,7 @@ export const POST = withRouteContext( opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown')) return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, { requestId, - details: { providerError: result.error }, + details: { retryable: true }, }) } @@ -230,12 +322,6 @@ export const POST = withRouteContext( // follow-up steps degrade the response to PARTIAL: the user gets a // success toast with a sub-warning, and the audit trail records exactly // which sub-step broke. - const partialFailures: Array<{ step: string; reason: string }> = [] - - if (paymentLinkFailure) { - partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure }) - } - // Optimistic-locked flip (draft → sent). Two concurrent sends can both // pass the draft guard above and both email the customer, but only the // request that wins this compare-and-set runs the bookkeeping steps @@ -245,8 +331,7 @@ export const POST = withRouteContext( // A genuine update error also skips the follow-ups: the row is still // 'draft', so a later retry re-runs the whole pipeline and ends with // exactly one journal entry (at the cost of a duplicate email). - let statusFlipped = false - { + if (!isCreditNote) { const { data: flipRows, error: updateError } = await supabase .from('invoices') .update({ status: 'sent' }) @@ -270,10 +355,10 @@ export const POST = withRouteContext( } const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' - const accountingMethod = (company as Record).accounting_method as string | undefined - let createdJournalEntryId: string | undefined + const accountingMethod = ((company as Record).accounting_method || 'accrual') as AccountingMethod + let createdJournalEntryId: string | undefined = creditJournalEntryId ?? undefined - if (statusFlipped && isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) { + if (statusFlipped && !isCreditNote && isRealInvoice && accountingMethod === 'accrual') { try { const journalEntry = await createInvoiceJournalEntry( supabase, @@ -312,7 +397,7 @@ export const POST = withRouteContext( opLog.error('failed to create invoice journal entry on send', err as Error) partialFailures.push({ step: 'journal_entry', - reason: err instanceof Error ? err.message : 'unknown', + reason: 'Fakturans verifikat kunde inte skapas.', }) } } @@ -332,7 +417,7 @@ export const POST = withRouteContext( opLog.error('failed to store invoice PDF as underlag', err as Error) partialFailures.push({ step: 'pdf_archive', - reason: err instanceof Error ? err.message : 'unknown', + reason: 'Fakturans PDF kunde inte arkiveras.', }) } } @@ -340,7 +425,7 @@ export const POST = withRouteContext( // Gated like the steps above: on a lost race the winning request emits // it; on a flip error the row is still 'draft', so emitting would // contradict DB state and the retry emits it instead. - if (statusFlipped) { + if (statusFlipped && !isCreditNote) { await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id }, @@ -356,7 +441,7 @@ export const POST = withRouteContext( return NextResponse.json({ success: true, - message: `Fakturan har skickats till ${customer.email} (kopia till ${ccAddress})`, + message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`, messageId: result.messageId, ...(partialFailures.length > 0 ? { partial: true, partial_failures: partialFailures } diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index b85cd361..8840a28d 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -41,12 +41,6 @@ vi.mock('@/lib/currency/riksbanken', () => ({ convertToSEK: vi.fn(), })) -const mockCreateCreditNoteJournalEntry = vi.fn() -vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ - createCreditNoteJournalEntry: (...args: unknown[]) => - mockCreateCreditNoteJournalEntry(...args), -})) - import { GET, POST } from '../route' describe('GET /api/invoices', () => { @@ -419,7 +413,7 @@ describe('POST /api/invoices (create credit note)', () => { expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT') }) - it('creates credit note with negated amounts and emits event', async () => { + it('creates a credit note draft without booking or crediting the original', async () => { const items = [ { id: 'item-1', @@ -449,25 +443,21 @@ describe('POST /api/invoices (create credit note)', () => { subtotal: -10000, vat_amount: -2500, total: -12500, - status: 'sent', + status: 'draft', }) // Fetch original invoice enqueue({ data: original, error: null }) + // No existing credit-note draft + enqueue({ data: null, error: null }) // Insert credit note enqueue({ data: creditNote, error: null }) // Insert credit note items enqueue({ data: null, error: null }) - // Update original status to 'credited' + // Mark creation complete enqueue({ data: null, error: null }) // Fetch complete credit note enqueue({ data: { ...creditNote, items: [] }, error: null }) - // Fetch company settings for entity type - enqueue({ data: { entity_type: 'enskild_firma' }, error: null }) - - mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'je-1' }) - // Update credit note with journal_entry_id - enqueue({ data: null, error: null }) const emitSpy = vi.spyOn(eventBus, 'emit') @@ -476,13 +466,35 @@ describe('POST /api/invoices (create credit note)', () => { body: { credited_invoice_id: VALID_UUID }, }) const response = await POST(request) - const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response) expect(status).toBe(200) - expect(body.data).toBeTruthy() - expect(emitSpy).toHaveBeenCalledWith( - expect.objectContaining({ type: 'credit_note.created' }) - ) + expect(body.data.status).toBe('draft') + expect(emitSpy).not.toHaveBeenCalled() + expect(mockSupabase.from).toHaveBeenCalledTimes(6) + }) + + it('returns an existing credit-note draft instead of creating a duplicate', async () => { + const original = makeInvoice({ id: VALID_UUID, status: 'sent' }) + const existing = makeInvoice({ + id: 'credit-existing', + invoice_number: 'KR-F-2024001', + status: 'draft', + credited_invoice_id: VALID_UUID, + }) + enqueue({ data: original, error: null }) + enqueue({ data: existing, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: VALID_UUID }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(200) + expect(body.data.id).toBe('credit-existing') + expect(mockSupabase.from).toHaveBeenCalledTimes(2) }) it('rolls back credit note when items insertion fails', async () => { @@ -508,6 +520,7 @@ describe('POST /api/invoices (create credit note)', () => { const creditNote = makeInvoice({ id: 'cn-1' }) enqueue({ data: original, error: null }) + enqueue({ data: null, error: null }) enqueue({ data: creditNote, error: null }) // Items fail enqueue({ data: null, error: { message: 'Items insert failed' } }) diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 6dc39e2d..c81f96dc 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -3,11 +3,10 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas' -import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types' -import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries' -import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service' +import type { Invoice, InvoiceDocumentType, InvoiceItem } from '@/types' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write' +import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { Logger } from '@/lib/logger' @@ -253,10 +252,6 @@ async function createCreditNote( log: Logger, requestId: string, ) { - // Non-blocking issues (e.g. partial accrual cancellation) surfaced to the - // caller alongside the created credit note. - const warnings: Array<{ code: string; message: string }> = [] - const { data: originalInvoice, error: originalError } = await supabase .from('invoices') .select('*, items:invoice_items(*)') @@ -286,6 +281,59 @@ async function createCreditNote( }) } + // Returning the existing credit note makes the action idempotent. A + // cancelled, unissued draft is reopened so the deterministic KR number can + // be reused without colliding with the company-wide invoice-number key. + const { data: existingCreditNote, error: existingCreditNoteError } = await supabase + .from('invoices') + .select('*, customer:customers(*), items:invoice_items(*)') + .eq('credited_invoice_id', input.credited_invoice_id) + .eq('company_id', companyId) + .eq('creation_complete', true) + .maybeSingle() + + if (existingCreditNoteError) { + log.error('failed to check for an existing credit note', existingCreditNoteError) + return errorResponse(existingCreditNoteError, log, { requestId }) + } + if (existingCreditNote) { + if (existingCreditNote.status === 'cancelled' && !existingCreditNote.journal_entry_id) { + const today = new Date().toISOString().split('T')[0] + const { error: reopenError } = await supabase + .from('invoices') + .update({ + status: 'draft', + invoice_date: today, + due_date: today, + notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`, + updated_at: new Date().toISOString(), + }) + .eq('id', existingCreditNote.id) + .eq('company_id', companyId) + .eq('status', 'cancelled') + + if (reopenError) { + log.error('failed to reopen cancelled credit note draft', reopenError) + return errorResponse(reopenError, log, { requestId }) + } + + const { data: reopenedCreditNote, error: reopenedError } = await supabase + .from('invoices') + .select('*, customer:customers(*), items:invoice_items(*)') + .eq('id', existingCreditNote.id) + .eq('company_id', companyId) + .single() + + if (reopenedError || !reopenedCreditNote) { + return errorResponse(reopenedError ?? new Error('Credit note draft not found'), log, { + requestId, + }) + } + return NextResponse.json({ data: reopenedCreditNote }) + } + return NextResponse.json({ data: existingCreditNote }) + } + const creditNoteNumber = `KR-${originalInvoice.invoice_number}` const { data: creditNote, error: creditNoteError } = await supabase @@ -313,17 +361,33 @@ async function createCreditNote( reverse_charge_text: originalInvoice.reverse_charge_text, your_reference: originalInvoice.your_reference, our_reference: originalInvoice.our_reference, + deduction_total: originalInvoice.deduction_total + ? -Math.abs(originalInvoice.deduction_total) + : 0, + deduction_personnummer_encrypted: originalInvoice.deduction_personnummer_encrypted ?? null, + deduction_personnummer_last4: originalInvoice.deduction_personnummer_last4 ?? null, notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`, credited_invoice_id: input.credited_invoice_id, // Copy the original's dimension bag so the credit-note verifikat nets // against the same dimension cells in reports (dimensions PR7). default_dimensions: originalInvoice.default_dimensions ?? {}, - status: 'sent', + status: 'draft', + creation_complete: false, }) .select() .single() if (creditNoteError) { + if (creditNoteError.code === '23505') { + const { data: racedCreditNote } = await supabase + .from('invoices') + .select('*, customer:customers(*), items:invoice_items(*)') + .eq('credited_invoice_id', input.credited_invoice_id) + .eq('company_id', companyId) + .eq('creation_complete', true) + .maybeSingle() + if (racedCreditNote) return NextResponse.json({ data: racedCreditNote }) + } log.error('credit note insert failed', creditNoteError) return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, { requestId, @@ -331,40 +395,24 @@ async function createCreditNote( }) } - const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null; dimensions?: Record }) => ({ - invoice_id: creditNote.id, - sort_order: item.sort_order, - line_type: item.line_type ?? 'product', - description: item.description, - quantity: -Math.abs(item.quantity), - unit: item.unit, - unit_price: item.unit_price, - line_total: -Math.abs(item.line_total), - vat_rate: item.vat_rate ?? 0, - vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0), - // Carry the original's per-line revenue-account override so the reversal - // hits the SAME account it originally credited (e.g. 3041, not the - // VAT-derived 3001): otherwise the override account keeps a dangling - // balance. article_id is preserved for the usage history. - revenue_account: item.revenue_account ?? null, - article_id: item.article_id ?? null, - // Same reasoning for periodiserade lines: the credit-note verifikat must - // reverse against the 29xx interim account the original credited, not the - // revenue account. generatePerRateLines reads these fields to substitute. - // No schedule is ever created for a credit note (only send/mark-sent - // create schedules); the original's schedule is cancelled below. - accrual_period_start: item.accrual_period_start ?? null, - accrual_period_end: item.accrual_period_end ?? null, - accrual_balance_account: item.accrual_balance_account ?? null, - // Same reasoning as revenue_account: the reversal must carry the exact - // per-item bag the original booked with (dimensions PR7). - dimensions: item.dimensions ?? {}, - })) + const creditNoteItems = (originalInvoice.items || []).map((item: InvoiceItem) => + buildCreditNoteItem(creditNote.id, item) + ) const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems) if (itemsError) { - await supabase.from('invoices').delete().eq('id', creditNote.id) + const { error: cleanupError } = await supabase + .from('invoices') + .delete() + .eq('id', creditNote.id) + .eq('company_id', companyId) + .eq('creation_complete', false) + if (cleanupError) { + log.error('failed to clean up incomplete credit note', cleanupError, { + creditNoteId: creditNote.id, + }) + } log.error('credit note items insert failed; rolled back', itemsError, { creditNoteId: creditNote.id, }) @@ -374,91 +422,35 @@ async function createCreditNote( }) } - await supabase + const { error: completionError } = await supabase .from('invoices') - .update({ status: 'credited' }) - .eq('id', input.credited_invoice_id) + .update({ creation_complete: true, updated_at: new Date().toISOString() }) + .eq('id', creditNote.id) + .eq('company_id', companyId) + .eq('creation_complete', false) - const { data: completeCreditNote } = await supabase + if (completionError) { + log.error('failed to mark credit note creation complete', completionError, { + creditNoteId: creditNote.id, + }) + return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId }) + } + + const { data: completeCreditNote, error: completeCreditNoteError } = await supabase .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') .eq('id', creditNote.id) - .single() - - const { data: creditNoteSettings } = await supabase - .from('company_settings') - .select('entity_type, accounting_method') .eq('company_id', companyId) + .eq('creation_complete', true) .single() - const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma' - const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual' - - // Cash method skips: there's no original invoice JE to reverse, recognition - // is deferred until refund. - if (completeCreditNote && accountingMethod === 'accrual') { - try { - const journalEntry = await createCreditNoteJournalEntry( - supabase, - companyId, - userId, - completeCreditNote as Invoice, - entityType, - completeCreditNote.customer?.name, - ) - if (journalEntry) { - await supabase - .from('invoices') - .update({ journal_entry_id: journalEntry.id }) - .eq('id', creditNote.id) - } - } catch (err) { - log.error('failed to create credit note journal entry', err as Error, { - creditNoteId: creditNote.id, - }) - // Non-blocking: credit note still exists. - } - - // Periodisering interplay: cancel remaining months and storno posted - // dissolutions so origin + dissolutions + stornos + credit net to zero on - // both 29xx and 3xxx. Best-effort: never blocks the credit itself, but - // partial reversals are surfaced as a response warning so the user knows - // the schedule stayed active. - try { - const cancelResult = await cancelSchedulesForSource( - supabase, - companyId, - userId, - { invoiceId: input.credited_invoice_id }, - { reversalDate: creditNote.invoice_date }, - ) - if (cancelResult.failedReversals > 0) { - warnings.push({ - code: 'ACCRUAL_CANCEL_PARTIAL', - message: - 'Fakturan krediterades, men en eller flera periodiseringsverifikat ' + - 'kunde inte vändas. Periodiseringen är fortfarande aktiv: ' + - 'kontrollera under Bokföring → Periodiseringar.', - }) - } - } catch (err) { - log.warn('failed to cancel accrual schedules for credited invoice', err as Error) - warnings.push({ - code: 'ACCRUAL_CANCEL_PARTIAL', - message: - 'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' + - 'Kontrollera under Bokföring → Periodiseringar.', - }) - } - - await eventBus.emit({ - type: 'credit_note.created', - payload: { creditNote: completeCreditNote as CreditNote, companyId, userId }, - }) + if (completeCreditNoteError || !completeCreditNote) { + log.error('failed to read completed credit note', completeCreditNoteError) + return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId }) } - return NextResponse.json({ - data: completeCreditNote, - ...(warnings.length > 0 ? { warnings } : {}), - }) + // A credit note is only issued when the user sends it or marks it as sent. + // Until then it is a non-editable draft: no journal entry is created and + // the original invoice remains in its current state. + return NextResponse.json({ data: completeCreditNote }) } diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index 0e8206e4..4c7f0efb 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -84,6 +84,88 @@ describe('PUT /api/settings', () => { expect(body.data.company_name).toBe('New Name') }) + it('updates all three reminder thresholds', async () => { + enqueueMany([ + { + data: { + entity_type: 'aktiebolag', + onboarding_complete: true, + reminder_days_level_1: 15, + reminder_days_level_2: 30, + reminder_days_level_3: 45, + }, + }, + { + data: { + id: 's1', + reminder_days_level_1: 7, + reminder_days_level_2: 21, + reminder_days_level_3: 35, + }, + }, + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { + reminder_days_level_1: 7, + reminder_days_level_2: 21, + reminder_days_level_3: 35, + }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ + data: { reminder_days_level_1: number; reminder_days_level_2: number; reminder_days_level_3: number } + }>(response) + + expect(status).toBe(200) + expect(body.data).toMatchObject({ + reminder_days_level_1: 7, + reminder_days_level_2: 21, + reminder_days_level_3: 35, + }) + }) + + it('returns 400 when reminder thresholds are not increasing', async () => { + enqueue({ + data: { + reminder_days_level_1: 15, + reminder_days_level_2: 30, + reminder_days_level_3: 45, + }, + }) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { + reminder_days_level_1: 30, + reminder_days_level_2: 20, + reminder_days_level_3: 45, + }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('returns 404 when the settings row does not exist', async () => { + enqueueMany([ + { data: { onboarding_complete: false } }, + { data: null, error: { code: 'PGRST116', message: 'No rows returned' } }, + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { reminder_days_level_1: 10 }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(404) + }) + it('blocks a vacation-year basis change while open balances exist', async () => { enqueueMany([ { data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 2d9aee91..40864825 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -40,7 +40,7 @@ export const PUT = withRouteContext( // Fetch current settings to check for tax-relevant changes const { data: oldSettings } = await supabase .from('company_settings') - .select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis') + .select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3') .eq('company_id', companyId) .single() @@ -48,6 +48,18 @@ export const PUT = withRouteContext( if (!validation.success) return validation.response const body = validation.data + const reminderDays = [ + body.reminder_days_level_1 ?? oldSettings?.reminder_days_level_1 ?? 15, + body.reminder_days_level_2 ?? oldSettings?.reminder_days_level_2 ?? 30, + body.reminder_days_level_3 ?? oldSettings?.reminder_days_level_3 ?? 45, + ] + if (!(reminderDays[0] < reminderDays[1] && reminderDays[1] < reminderDays[2])) { + return NextResponse.json( + { error: 'Påminnelsedagarna måste ligga i stigande ordning.' }, + { status: 400 }, + ) + } + // Lock org_number after onboarding is complete (legal identifier: changing it // would orphan vouchers, SIE history, and tax filings). company_name remains // editable so users can update their display/brand name (e.g. särskilt företagsnamn). @@ -121,6 +133,9 @@ export const PUT = withRouteContext( .single() if (error) { + if (error.code === 'PGRST116') { + return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 }) + } return NextResponse.json({ error: error.message }, { status: 500 }) } diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index 9103b7e1..79c95d0d 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -39,6 +39,11 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ mockCreateSupplierInvoicePrivatelyPaidEntry(...args), })) +const mockLinkToJournalEntry = vi.fn() +vi.mock('@/lib/core/documents/document-service', () => ({ + linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args), +})) + import { eventBus } from '@/lib/events' import { GET, POST } from '../route' @@ -128,6 +133,7 @@ describe('GET /api/supplier-invoices', () => { const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001' +const DOCUMENT_UUID = '550e8400-e29b-41d4-a716-446655440002' describe('POST /api/supplier-invoices', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -221,6 +227,77 @@ describe('POST /api/supplier-invoices', () => { expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled() }) + it('stores an uploaded document and links it to the registration entry', async () => { + const supplier = makeSupplier({ id: VALID_UUID }) + const createdInvoice = makeSupplierInvoice({ id: 'si-with-document', document_id: DOCUMENT_UUID }) + + enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null }) + enqueue({ data: null, error: null }) + enqueue({ data: supplier, error: null }) + enqueue({ data: 6 }) + enqueue({ data: createdInvoice, error: null }) + enqueue({ data: null, error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-document' }) + enqueue({ data: null, error: null }) + mockLinkToJournalEntry.mockResolvedValue({ id: DOCUMENT_UUID }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: VALID_UUID, + document_id: DOCUMENT_UUID, + supplier_invoice_number: 'LF-DOCUMENT', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [ + { description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' }, + ], + }, + }) + + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { document_id: string; registration_journal_entry_id: string } + }>(response) + + expect(status).toBe(200) + expect(body.data.document_id).toBe(DOCUMENT_UUID) + expect(body.data.registration_journal_entry_id).toBe('je-document') + expect(mockLinkToJournalEntry).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + DOCUMENT_UUID, + 'je-document', + ) + }) + + it('rejects a document that is missing or outside the active company', async () => { + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: VALID_UUID, + document_id: DOCUMENT_UUID, + supplier_invoice_number: 'LF-INVALID-DOCUMENT', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [ + { description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' }, + ], + }, + }) + + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT') + expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() + expect(mockLinkToJournalEntry).not.toHaveBeenCalled() + }) + it('emits supplier_invoice.registered event', async () => { const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 0a9e1975..3ec706e0 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -12,6 +12,7 @@ import { validateBody } from '@/lib/api/validate' import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { linkToJournalEntry } from '@/lib/core/documents/document-service' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' ensureInitialized() @@ -68,6 +69,42 @@ export const POST = withRouteContext( const body = validation.data const paidPrivately = body.paid_with_private_funds === true + if (body.document_id) { + const { data: document, error: documentError } = await supabase + .from('document_attachments') + .select('id, journal_entry_id') + .eq('id', body.document_id) + .eq('company_id', companyId) + .maybeSingle() + + if (documentError || !document || document.journal_entry_id) { + return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, { + requestId, + details: { reason: 'document_id is missing, belongs to another company, or is already linked' }, + }) + } + + const { data: existingDocumentUse, error: existingDocumentUseError } = await supabase + .from('supplier_invoices') + .select('id') + .eq('company_id', companyId) + .eq('document_id', body.document_id) + .limit(1) + .maybeSingle() + + if (existingDocumentUseError) { + log.error('supplier invoice document usage lookup failed', existingDocumentUseError) + return errorResponse(existingDocumentUseError, log, { requestId }) + } + + if (existingDocumentUse) { + return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, { + requestId, + details: { reason: 'document_id is already used by a supplier invoice' }, + }) + } + } + if (paidPrivately && body.reverse_charge) { // RC invoices come from registered businesses with formal invoices and // go through normal AP. "Privately paid" only makes sense for @@ -236,6 +273,7 @@ export const POST = withRouteContext( user_id: user.id, company_id: companyId, supplier_id: body.supplier_id, + document_id: body.document_id || null, arrival_number: arrivalNum, supplier_invoice_number: body.supplier_invoice_number, invoice_date: body.invoice_date, @@ -502,6 +540,28 @@ export const POST = withRouteContext( } } + const primaryJournalEntryId = paymentJournalEntryId || registrationJournalEntryId + if (body.document_id && primaryJournalEntryId) { + try { + await linkToJournalEntry( + supabase, + companyId, + body.document_id, + primaryJournalEntryId, + ) + } catch (err) { + log.warn('supplier invoice document could not be linked to journal entry', { + documentId: body.document_id, + journalEntryId: primaryJournalEntryId, + error: err instanceof Error ? err.message : String(err), + }) + warnings.push({ + code: 'DOCUMENT_LINK_FAILED', + message: 'Fakturan registrerades, men underlaget kunde inte kopplas till verifikationen.', + }) + } + } + try { await eventBus.emit({ type: 'supplier_invoice.registered', diff --git a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts index 6080280b..7728601d 100644 --- a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts +++ b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts @@ -309,6 +309,43 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => { expect(body.error.code).toBe('LINK_TX_INVOICE_NOT_OPEN') }) + it('returns 400 before linking when supplied invoice is a credit note', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000 }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ + id: INV_UUID, + status: 'sent', + total: -1000, + remaining_amount: -1000, + credited_invoice_id: 'original-invoice-1', + }), + error: null, + }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('LINK_TX_INVOICE_CREDIT_NOTE') + }) + it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses and rolls back the tx link', async () => { enqueue({ data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index 2e614862..0925068d 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -196,6 +196,51 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_INVOICE_TYPE') }) + it('rejects matching an original invoice with an active credit-note draft', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) + const invoice = { + ...makeInvoice({ id: VALID_UUID, status: 'sent', credited_invoice_id: null }), + credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }], + } + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(response.status).toBe(400) + expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE') + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 400 before booking when matching against a credit note', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) + const creditNote = makeInvoice({ + id: VALID_UUID, + status: 'sent', + total: -12500, + credited_invoice_id: 'original-invoice-1', + }) + enqueue({ data: tx, error: null }) + enqueue({ data: creditNote, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE') + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled() + }) + it('returns 400 when invoice is not in unpaid state', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' }) diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index f82c0f49..8e197bcf 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -82,7 +82,7 @@ export const POST = withRouteContext( const { data: invoice, error: fetchInvError } = await supabase .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') + .select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)') .eq('id', invoice_id) .eq('company_id', companyId) .single() @@ -104,6 +104,23 @@ export const POST = withRouteContext( }) } + if (invoice.credited_invoice_id) { + return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, { requestId }) + } + + const activeCreditNotes = ((invoice as { credit_notes?: Array<{ + status: string + creation_complete?: boolean + }> }).credit_notes ?? []).filter( + (creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false, + ) + if (activeCreditNotes.length > 0) { + return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, { + requestId, + details: { reason: 'active_credit_note' }, + }) + } + if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') { return errorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, { requestId, diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts index fc79650d..c1aed7ee 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts @@ -571,6 +571,47 @@ describe('POST :id/match-invoice', () => { expect((await res.json()).error.code).toBe('MATCH_INVOICE_TX_ALREADY_LINKED') }) + it('rejects a credit note before creating a payment journal entry', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { + data: { + id: TX_ID, + amount: 12500, + date: '2026-05-12', + currency: 'SEK', + invoice_id: null, + }, + error: null, + }, + invoices: { + data: { + id: INV_ID, + status: 'sent', + document_type: 'invoice', + total: -12500, + credited_invoice_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + }, + error: null, + }, + }), + ) + + const res = await matchInvoicePOST( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`, + { invoice_id: INV_ID }, + ), + txParams(TX_ID), + ) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('MATCH_INVOICE_CREDIT_NOTE') + expect(createInvPmtJE).not.toHaveBeenCalled() + expect(createInvCashJE).not.toHaveBeenCalled() + }) + // The v1 route threads resolveSettlementAccount(transaction.cash_account_id) // exactly like the dashboard route and the agent/MCP commit path; these // regression tests were missing here (flagged in triage on #987) even diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index 5487b2ca..19293cc2 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -180,6 +180,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string details: { documentType: docType }, }) } + if (invoice.credited_invoice_id) { + return v1ErrorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, { + requestId: ctx.requestId, + }) + } if ( invoice.status !== 'sent' && invoice.status !== 'overdue' && diff --git a/components/bookkeeping/DocumentUploadZone.tsx b/components/bookkeeping/DocumentUploadZone.tsx index a9b483cd..e5aea4ca 100644 --- a/components/bookkeeping/DocumentUploadZone.tsx +++ b/components/bookkeeping/DocumentUploadZone.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useCallback, useRef } from 'react' +import { useLocale, useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Upload, FileText, ImageIcon, X, Loader2 } from 'lucide-react' @@ -45,10 +46,13 @@ function isImageType(type: string): boolean { * returned by /api/documents. Falls back to message_en or null if the * shape is unexpected. */ -function extractErrorMessage(err: unknown): string | null { +function extractErrorMessage(err: unknown, locale: string): string | null { if (typeof err === 'string') return err if (err && typeof err === 'object') { const e = err as { message?: unknown; message_en?: unknown; code?: unknown } + if (locale === 'en' && typeof e.message_en === 'string' && e.message_en.length > 0) { + return e.message_en + } if (typeof e.message === 'string' && e.message.length > 0) return e.message if (typeof e.message_en === 'string' && e.message_en.length > 0) return e.message_en if (typeof e.code === 'string') return e.code @@ -64,6 +68,8 @@ export default function DocumentUploadZone({ disabled = false, compact = false, }: DocumentUploadZoneProps) { + const t = useTranslations('document_upload') + const locale = useLocale() const [isDragging, setIsDragging] = useState(false) const inputRef = useRef(null) @@ -91,13 +97,13 @@ export default function DocumentUploadZone({ fileName: file.fileName, }) const reason = res.status === 401 || res.status === 403 - ? 'Din session har gått ut. Ladda om sidan och logga in igen.' - : `Servern svarade ${res.status}.` + ? t('session_expired') + : t('server_status', { status: res.status }) return { ...file, status: 'error', error: reason } } if (!res.ok || result.error) { - const errMessage = extractErrorMessage(result.error) || `Uppladdning misslyckades (${res.status})` + const errMessage = extractErrorMessage(result.error, locale) || t('failed_status', { status: res.status }) console.warn('[DocumentUploadZone] Upload error', { status: res.status, error: result.error, @@ -112,9 +118,9 @@ export default function DocumentUploadZone({ error: err, fileName: file.fileName, }) - return { ...file, status: 'error', error: 'Uppladdning misslyckades: nätverksfel' } + return { ...file, status: 'error', error: t('network_error') } } - }, [journalEntryId]) + }, [journalEntryId, locale, t]) const handleFiles = useCallback(async (newFiles: File[]) => { const remaining = maxFiles - files.length @@ -127,7 +133,7 @@ export default function DocumentUploadZone({ validFiles.push({ file, status: 'error', - error: 'Filtypen stöds inte', + error: t('unsupported_type'), fileName: file.name, fileSize: file.size, uploadKey: `upload-${++uploadCounter}`, @@ -138,7 +144,7 @@ export default function DocumentUploadZone({ validFiles.push({ file, status: 'error', - error: 'Filen är för stor (max 10 MB)', + error: t('too_large'), fileName: file.name, fileSize: file.size, uploadKey: `upload-${++uploadCounter}`, @@ -165,7 +171,7 @@ export default function DocumentUploadZone({ ) onFilesChange([...currentFiles]) } - }, [files, maxFiles, onFilesChange, uploadFile]) + }, [files, maxFiles, onFilesChange, t, uploadFile]) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() @@ -230,11 +236,11 @@ export default function DocumentUploadZone({

    - {compact ? 'Dra och släpp eller klicka' : 'Dra och släpp filer här'} + {compact ? t('compact_prompt') : t('prompt')}

    {!compact && (

    - PDF, bilder (max 10 MB) + {t('format_hint')}

    )}
    @@ -264,13 +270,13 @@ export default function DocumentUploadZone({ )} {file.status === 'uploaded' && ( - Uppladdad + {t('uploaded')} )} {file.status === 'error' && ( <> - Fel + {t('error')} {file.error && ( {file.error} @@ -281,7 +287,7 @@ export default function DocumentUploadZone({
) diff --git a/components/bookkeeping/JournalEntryAttachments.tsx b/components/bookkeeping/JournalEntryAttachments.tsx index b843fba0..88766da9 100644 --- a/components/bookkeeping/JournalEntryAttachments.tsx +++ b/components/bookkeeping/JournalEntryAttachments.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, @@ -38,6 +39,7 @@ interface DocumentRecord { storage_path: string created_at: string download_url?: string + referenced?: boolean } interface JournalEntryAttachmentsProps { @@ -90,12 +92,45 @@ export default function JournalEntryAttachments({ const fetchDocuments = useCallback(async () => { try { - const res = await fetch( - `/api/documents?journal_entry_id=${journalEntryId}¤t_only=true` - ) - const { data } = await res.json() - setDocuments(data || []) - onCountChangeRef.current?.(data?.length || 0) + const [documentsRes, referencesRes] = await Promise.all([ + fetch(`/api/documents?journal_entry_id=${journalEntryId}¤t_only=true`), + fetch(`/api/bookkeeping/journal-entries/${journalEntryId}/references`), + ]) + const { data: directDocuments } = await documentsRes.json() + const direct = (directDocuments || []) as DocumentRecord[] + const directIds = new Set(direct.map((document) => document.id)) + + let referenced: DocumentRecord[] = [] + if (referencesRes.ok) { + const { data: referenceData } = await referencesRes.json() + const documentIds = Array.from(new Set( + (referenceData?.references || []) + .map((reference: { document_id?: string }) => reference.document_id) + .filter((documentId: string | undefined): documentId is string => ( + Boolean(documentId) && !directIds.has(documentId as string) + )), + )) + + const referencedDocuments = await Promise.all( + documentIds.map(async (documentId) => { + try { + const response = await fetch(`/api/documents/${documentId}`) + if (!response.ok) return null + const { data } = await response.json() + return data ? { ...data, referenced: true } as DocumentRecord : null + } catch { + return null + } + }), + ) + referenced = referencedDocuments.filter( + (document): document is DocumentRecord => document !== null, + ) + } + + const allDocuments = [...direct, ...referenced] + setDocuments(allDocuments) + onCountChangeRef.current?.(allDocuments.length) } catch { // Non-critical: silently ignore } finally { @@ -286,36 +321,45 @@ export default function JournalEntryAttachments({ )} {doc.file_name} + {doc.referenced && ( + + {t('via_supplier_invoice')} + + )} {formatFileSize(doc.file_size_bytes)} - + {!doc.referenced && ( + <> + - + + + )}
- {!isEditMode && ( + {isCopyMode && copyInitial && ( +
+ +

+ {t('copy_notice', { number: copyInitial.source_invoice_number })} +

+
+ )} + + {!isEditMode && !isCopyMode && ( setMode(v as 'invoice' | 'self_billed')}> {t('mode_invoice')} diff --git a/components/invoices/NewInvoiceDialog.tsx b/components/invoices/NewInvoiceDialog.tsx index 899db82f..c276b4b0 100644 --- a/components/invoices/NewInvoiceDialog.tsx +++ b/components/invoices/NewInvoiceDialog.tsx @@ -1,12 +1,23 @@ 'use client' import dynamic from 'next/dynamic' +import { useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' +import { Button } from '@/components/ui/button' +import { createClient } from '@/lib/supabase/client' +import { useCompany } from '@/contexts/CompanyContext' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + buildInvoiceCopyInitial, + canCopyInvoice, + type InvoiceCopyInitial, + type InvoiceCopySource, +} from '@/lib/invoices/copy-invoice' // Deferred: the editor (and its framer-motion dependency) is a large chunk -// that would otherwise ship with the invoice LIST bundle — it's only needed +// that would otherwise ship with the invoice LIST bundle: it is only needed // once this dialog actually opens. const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor'), { ssr: false, @@ -22,6 +33,7 @@ const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor' interface Props { open: boolean onOpenChange: (open: boolean) => void + copyFromId?: string | null } /** @@ -35,8 +47,70 @@ interface Props { * shows the invoice-number preview: a static DialogTitle would duplicate or * contradict it. */ -export default function NewInvoiceDialog({ open, onOpenChange }: Props) { +export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null }: Props) { const t = useTranslations('invoice_editor') + const { company } = useCompany() + const supabase = useMemo(() => createClient(), []) + const [copyLoad, setCopyLoad] = useState<{ + sourceId: string | null + initial: InvoiceCopyInitial | null + failed: boolean + }>({ sourceId: null, initial: null, failed: false }) + + useEffect(() => { + if (!open || !copyFromId) return + if (!company?.id) return + + let cancelled = false + + const loadCopySource = async () => { + const { data, error } = await supabase + .from('invoices') + .select('*') + .eq('id', copyFromId) + .eq('company_id', company.id) + .single() + + let items: Record[] = [] + if (!error && data) { + try { + items = await fetchAllRows>(({ from, to }) => + supabase + .from('invoice_items') + .select('*') + .eq('invoice_id', copyFromId) + .eq('company_id', company.id) + .order('id', { ascending: true }) + .range(from, to), + ) + } catch { + if (!cancelled) setCopyLoad({ sourceId: copyFromId, initial: null, failed: true }) + return + } + } + + if (cancelled) return + const source = data ? { ...data, items } : null + if (error || !source || !canCopyInvoice(source)) { + setCopyLoad({ sourceId: copyFromId, initial: null, failed: true }) + return + } + setCopyLoad({ + sourceId: copyFromId, + initial: buildInvoiceCopyInitial(source as InvoiceCopySource), + failed: false, + }) + } + + void loadCopySource() + + return () => { + cancelled = true + } + }, [company?.id, copyFromId, open, supabase]) + + const copyInitial = copyLoad.sourceId === copyFromId ? copyLoad.initial : null + const copyLoadFailed = copyLoad.sourceId === copyFromId && copyLoad.failed return ( @@ -50,8 +124,30 @@ export default function NewInvoiceDialog({ open, onOpenChange }: Props) { onPointerDownOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > - {t('title_invoice')} - + + {copyFromId ? t('title_copy') : t('title_invoice')} + + {copyFromId ? ( + copyLoadFailed ? ( +
+

{t('copy_load_failed_title')}

+

{t('copy_load_failed_description')}

+ +
+ ) : copyInitial ? ( + + ) : ( +
+ + + +
+ ) + ) : ( + + )}
) diff --git a/components/invoices/SendInvoiceDialog.tsx b/components/invoices/SendInvoiceDialog.tsx index 25b03044..51b53644 100644 --- a/components/invoices/SendInvoiceDialog.tsx +++ b/components/invoices/SendInvoiceDialog.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useEffect, useMemo } from 'react' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { Dialog, DialogContent, @@ -16,8 +16,10 @@ import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntry import { proposeSendLines } from '@/lib/bookkeeping/propose-send-lines' import { formatCurrency } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' +import { getResponseErrorMessage } from '@/lib/errors/get-error-message' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' +import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note' import { Loader2, Mail, Send } from 'lucide-react' import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types' @@ -47,12 +49,16 @@ export default function SendInvoiceDialog({ const { company, isSandbox } = useCompany() const canEmail = useCapability(CAPABILITY.email_send) const t = useTranslations('invoice_send_dialog') + const locale = useLocale() as 'sv' | 'en' + const isCreditNote = !!invoice.credited_invoice_id + const isCreditRepair = isCreditNote && invoice.status === 'sent' const [isSubmitting, setIsSubmitting] = useState(false) const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') const [entityType, setEntityType] = useState('enskild_firma') const [periodName, setPeriodName] = useState('') const [isInitialized, setIsInitialized] = useState(false) + const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true) useEffect(() => { if (!open) { @@ -66,30 +72,44 @@ export default function SendInvoiceDialog({ try { if (!company?.id) throw new Error(t('no_active_company')) - // Fetch company settings - const { data: settings, error } = await supabase - .from('company_settings') - .select('accounting_method, entity_type') - .eq('company_id', company.id) - .maybeSingle() + const [settingsResult, periodResult, originalResult] = await Promise.all([ + supabase + .from('company_settings') + .select('accounting_method, entity_type') + .eq('company_id', company.id) + .maybeSingle(), + supabase + .from('fiscal_periods') + .select('name') + .eq('company_id', company.id) + .lte('start_date', invoice.invoice_date) + .gte('end_date', invoice.invoice_date) + .maybeSingle(), + invoice.credited_invoice_id + ? supabase + .from('invoices') + .select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total') + .eq('id', invoice.credited_invoice_id) + .eq('company_id', company.id) + .maybeSingle() + : Promise.resolve({ data: null, error: null }), + ]) - if (error) throw new Error(t('company_settings_failed')) - if (cancelled) return - - // Fetch fiscal period for the invoice date - const { data: period } = await supabase - .from('fiscal_periods') - .select('name') - .eq('company_id', company.id) - .lte('start_date', invoice.invoice_date) - .gte('end_date', invoice.invoice_date) - .maybeSingle() + if (settingsResult.error) throw new Error(t('company_settings_failed')) + if (periodResult.error) throw new Error(t('fiscal_period_failed')) + if (originalResult.error) throw new Error(t('original_invoice_failed')) if (cancelled) return - setAccountingMethod((settings?.accounting_method || 'accrual') as 'accrual' | 'cash') - setEntityType((settings?.entity_type as EntityType) || 'enskild_firma') - setPeriodName(period?.name || '') + const method = (settingsResult.data?.accounting_method || 'accrual') as 'accrual' | 'cash' + setAccountingMethod(method) + setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma') + setPeriodName(periodResult.data?.name || '') + setShouldBookOnIssue( + invoice.credited_invoice_id && originalResult.data + ? creditNoteNeedsJournalEntry(method, originalResult.data) + : method === 'accrual', + ) setIsInitialized(true) } catch (err) { if (cancelled) return @@ -107,7 +127,7 @@ export default function SendInvoiceDialog({ }, [open, invoice.id, invoice.invoice_date, company?.id]) const proposedLines = useMemo(() => { - if (!isInitialized || accountingMethod !== 'accrual') return [] + if (!isInitialized || !shouldBookOnIssue) return [] return proposeSendLines({ invoice: { @@ -121,12 +141,13 @@ export default function SendInvoiceDialog({ currency: invoice.currency, exchange_rate: invoice.exchange_rate, vat_treatment: invoice.vat_treatment, + credited_invoice_id: invoice.credited_invoice_id, items: invoice.items, default_dimensions: invoice.default_dimensions, }, entityType, }) - }, [isInitialized, accountingMethod, entityType, invoice]) + }, [isInitialized, shouldBookOnIssue, entityType, invoice]) const { totalDebit, totalCredit } = useMemo(() => { let totalDebit = 0 @@ -147,33 +168,62 @@ export default function SendInvoiceDialog({ : `/api/invoices/${invoice.id}/mark-sent` const response = await fetch(url, { method: 'POST' }) - const data = await response.json() if (!response.ok) { - throw new Error(data.error || t('send_failed_fallback')) + throw new Error(await getResponseErrorMessage(response, 'invoice', locale)) } + const data = await response.json() onSuccess() if (mode === 'email') { onOpenChange(false) + const successMessage = data.message || t('send_success_default', { email: invoice.customer.email ?? '' }) toast({ - title: t('send_success_title'), - description: data.message || t('send_success_default', { email: invoice.customer.email ?? '' }), + title: t( + shouldBookOnIssue && !data.partial + ? isCreditNote + ? 'credit_send_book_success_title' + : 'send_book_success_title' + : isCreditNote + ? 'credit_send_success_title' + : 'send_success_title', + ), + description: data.partial + ? t('partial_success', { message: successMessage }) + : isCreditNote + ? t('credit_send_success', { email: invoice.customer.email ?? '' }) + : successMessage, }) } else { // For manual send, just close: no email to confirm onOpenChange(false) toast({ - title: t('mark_success_title'), - description: accountingMethod === 'accrual' - ? t('mark_success_voucher_created') - : undefined, + title: t( + isCreditRepair + ? 'credit_repair_success_title' + : shouldBookOnIssue && !data.partial + ? isCreditNote + ? 'credit_mark_book_success_title' + : 'mark_book_success_title' + : isCreditNote + ? 'credit_mark_success_title' + : 'mark_success_title', + ), + description: data.partial + ? t('mark_partial_success') + : isCreditNote + ? shouldBookOnIssue + ? t('credit_mark_success_voucher_created') + : t('credit_mark_success_no_voucher') + : accountingMethod === 'accrual' + ? t('mark_success_voucher_created') + : undefined, }) } } catch (error) { toast({ - title: t('send_failed_title'), + title: t(isCreditNote ? 'credit_send_failed_title' : 'send_failed_title'), description: error instanceof Error ? error.message : t('try_again'), variant: 'destructive', }) @@ -186,14 +236,25 @@ export default function SendInvoiceDialog({ onOpenChange(false) } - const showJournalPreview = accountingMethod === 'accrual' && proposedLines.length > 0 + const showJournalPreview = shouldBookOnIssue && proposedLines.length > 0 return ( - {mode === 'email' ? t('title_email') : t('title_manual')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''} + {t( + isCreditRepair + ? 'title_credit_repair' + : isCreditNote + ? mode === 'email' + ? 'title_credit_email' + : 'title_credit_manual' + : mode === 'email' + ? 'title_email' + : 'title_manual', + )} + {invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''} {formatCurrency(invoice.total, invoice.currency)} @@ -236,7 +297,7 @@ export default function SendInvoiceDialog({ ) : (

- {accountingMethod === 'cash' - ? t('explain_cash') + {!shouldBookOnIssue + ? t(isCreditNote ? 'explain_credit_cash' : 'explain_cash') : mode === 'email' ? t('explain_email', { email: invoice.customer.email ?? '' }) : t('explain_manual')} @@ -266,7 +327,7 @@ export default function SendInvoiceDialog({ disabled={isSubmitting} className="w-full sm:w-auto min-h-11" > - {t('cancel')} + {t(isCreditNote ? 'later' : 'cancel')} diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx index e1fd26c8..4e6e61d5 100644 --- a/components/settings/ApiKeysPanel.tsx +++ b/components/settings/ApiKeysPanel.tsx @@ -106,7 +106,7 @@ const SCOPE_GROUPS: ScopeGroup[] = [ { domain: 'companies', labelKey: 'group_companies', - read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 0 }, + read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 1 }, write: null, }, { diff --git a/components/settings/InvoiceSettingsForm.tsx b/components/settings/InvoiceSettingsForm.tsx index 01b1bc1f..920ab892 100644 --- a/components/settings/InvoiceSettingsForm.tsx +++ b/components/settings/InvoiceSettingsForm.tsx @@ -92,6 +92,46 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) { {t('default_our_reference_help')}

+ +
+ {t('reminder_days_heading')} +

{t('reminder_days_help')}

+
+
+ + +
+
+ + +
+
+ + +
+
+
) } diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx index 71bc777a..58213173 100644 --- a/components/settings/sections/InvoicingSettingsContent.tsx +++ b/components/settings/sections/InvoicingSettingsContent.tsx @@ -51,6 +51,12 @@ export function InvoicingSettingsContent() { invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30, invoice_default_notes: (formData.get('invoice_default_notes') as string) || null, default_our_reference: (formData.get('default_our_reference') as string) || null, + reminder_days_level_1: + Number.parseInt(formData.get('reminder_days_level_1') as string) || 15, + reminder_days_level_2: + Number.parseInt(formData.get('reminder_days_level_2') as string) || 30, + reminder_days_level_3: + Number.parseInt(formData.get('reminder_days_level_3') as string) || 45, } return { updates, diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx index 70da9aff..7815d2a2 100644 --- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx +++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx @@ -28,9 +28,10 @@ import { useCanWrite } from '@/lib/hooks/use-can-write' import BankTransactionPicker from '@/components/transactions/BankTransactionPicker' import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl' import LineDimensionFields from '@/components/dimensions/LineDimensionFields' +import DocumentUploadZone, { type UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions' import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute' -import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock, Tags } from 'lucide-react' +import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock, Tags, Paperclip } from 'lucide-react' import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types' interface LineItem { @@ -66,6 +67,7 @@ interface ExistingSupplierInvoice { // paths still return a flat string, so accept both. interface CreateResult { data?: { id: string; arrival_number: number } + warnings?: Array<{ code: string; message: string }> error?: | string | { @@ -303,6 +305,7 @@ export default function NewSupplierInvoiceForm({ // with onCreated (dialog mode), otherwise navigate like the old standalone // page did. const finishCreate = (invoiceId?: string) => { + createFinishedRef.current = true if (onCreated) onCreated(invoiceId) else router.push(afterCreate(invoiceId)) } @@ -331,6 +334,9 @@ export default function NewSupplierInvoiceForm({ const [pendingSupplierSelect, setPendingSupplierSelect] = useState(null) const [advancedOpen, setAdvancedOpen] = useState(false) const [newSupplier, setNewSupplier] = useState(EMPTY_NEW_SUPPLIER) + const [documentFiles, setDocumentFiles] = useState([]) + const documentFilesRef = useRef([]) + const createFinishedRef = useRef(false) // Inbox/AI state const [extractedData, setExtractedData] = useState(null) @@ -379,6 +385,19 @@ export default function NewSupplierInvoiceForm({ useUnsavedChanges(isDirty) + useEffect(() => { + documentFilesRef.current = documentFiles + }, [documentFiles]) + + useEffect(() => () => { + if (inboxItemId || createFinishedRef.current) return + for (const file of documentFilesRef.current) { + if (file.status === 'uploaded' && file.id) { + void fetch(`/api/documents/${file.id}`, { method: 'DELETE', keepalive: true }) + } + } + }, [inboxItemId]) + const { fields, append, remove, replace } = useFieldArray({ control, name: 'items' }) const watchedItems = watch('items') const watchedSupplierId = watch('supplier_id') @@ -392,6 +411,9 @@ export default function NewSupplierInvoiceForm({ const watchedInvoiceDate = watch('invoice_date') const watchedDueDate = watch('due_date') const watchedPaymentReference = watch('payment_reference') + const documentUploadInProgress = documentFiles.some((file) => file.status === 'uploading') + const documentUploadFailed = documentFiles.some((file) => file.status === 'error') + const uploadedDocumentId = documentFiles.find((file) => file.status === 'uploaded')?.id // Returns true when the field currently matches whatever the AI wrote // when the form first loaded. Edits diverge it, hiding the dot. function stillFromAi(value: string | null | undefined, original: string | null | undefined): boolean { @@ -943,6 +965,7 @@ export default function NewSupplierInvoiceForm({ : data.due_date return { supplier_id: data.supplier_id, + ...(!inboxItemId && uploadedDocumentId ? { document_id: uploadedDocumentId } : {}), supplier_invoice_number: data.supplier_invoice_number, invoice_date: data.invoice_date, due_date: dueDate, @@ -1044,10 +1067,36 @@ export default function NewSupplierInvoiceForm({ body: JSON.stringify(buildPayload(data)), }) const result = await res.json() + if ( + res.ok && + (result as CreateResult).warnings?.some((warning) => warning.code === 'DOCUMENT_LINK_FAILED') + ) { + toast({ + title: t('document_link_warning_title'), + description: t('document_link_warning_description'), + variant: 'destructive', + }) + } return { ok: res.ok, status: res.status, result } } function onSubmit(data: FormData) { + if (documentUploadInProgress) { + toast({ + title: t('document_upload_in_progress_title'), + description: t('document_upload_in_progress_description'), + variant: 'destructive', + }) + return + } + if (documentUploadFailed) { + toast({ + title: t('document_upload_failed_title'), + description: t('document_upload_failed_description'), + variant: 'destructive', + }) + return + } // Hard block: under faktureringsmetoden (and for privately-paid kvitton) a // verifikation is posted at registration, and BFL 5 kap kräver att // verifikationsnumret ligger i en obruten serie inom ett räkenskapsår. No @@ -1328,6 +1377,34 @@ export default function NewSupplierInvoiceForm({ setTimeout(() => invoiceNumberInputRef.current?.focus(), 0) } + function handleDocumentFilesChange(nextFiles: UploadedFile[]) { + const retainedKeys = new Set(nextFiles.map((file) => file.uploadKey)) + const removedDocuments = documentFiles.filter( + (file) => file.id && !retainedKeys.has(file.uploadKey), + ) + + setDocumentFiles(nextFiles) + for (const document of removedDocuments) { + void fetch(`/api/documents/${document.id}`, { method: 'DELETE' }) + } + } + + async function discardUploadedDocument() { + const ids = documentFiles + .filter((file) => file.status === 'uploaded' && file.id) + .map((file) => file.id as string) + await Promise.allSettled( + ids.map((documentId) => fetch(`/api/documents/${documentId}`, { method: 'DELETE' })), + ) + } + + function handleCancel() { + void discardUploadedDocument().finally(() => { + if (onCancel) onCancel() + else router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices') + }) + } + // Match-on-create: register the invoice, then match the picked transaction. // EF goes straight through (auto-approve included). AB stores the picked // transaction and routes through the same review dialog as the plain @@ -1559,6 +1636,25 @@ export default function NewSupplierInvoiceForm({ )} + {!inboxItemId && ( +
+
+ +
+ +

{t('document_help')}

+
+
+ +
+ )} + {/* Invoice-level default dims (kostnadsställe/projekt): applied to every generated journal line; per-row bags in Kontering merge on top. Renders only when dimensions are enabled for the company. */} @@ -2052,10 +2148,8 @@ export default function NewSupplierInvoiceForm({ type="button" variant="outline" className="w-full sm:w-auto" - onClick={() => { - if (onCancel) onCancel() - else router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices') - }} + onClick={handleCancel} + disabled={isSubmitting || documentUploadInProgress} > {t('cancel')} @@ -2064,7 +2158,7 @@ export default function NewSupplierInvoiceForm({ type="submit" variant="outline" className="w-full sm:w-auto" - disabled={isSubmitting || !canWrite || showNoPeriodWarning} + disabled={isSubmitting || documentUploadInProgress || !canWrite || showNoPeriodWarning} onClick={() => { submitModeRef.current = 'register_and_match' }} title={!canWrite ? t('viewer_disabled_tooltip') : undefined} > @@ -2074,7 +2168,7 @@ export default function NewSupplierInvoiceForm({ )}