Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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: >-
|
||||
|
||||
@@ -94,3 +94,5 @@ scripts/reopen-bokslut.sql
|
||||
|
||||
.claude/plans/write-up-a-plan-streamed-fiddle.md
|
||||
/ingaende-balanser-test.csv
|
||||
.agents
|
||||
.codex
|
||||
@@ -0,0 +1,106 @@
|
||||
# CLAUDE.md: Accounted
|
||||
|
||||
Swedish accounting SaaS: double-entry bookkeeping under Swedish accounting law (Bokföringslagen) for sole traders (enskild firma) and limited companies (aktiebolag). Multi-tenant: users belong to companies via `company_members`; `teams` group companies for consultants.
|
||||
|
||||
**Stack**: Next.js 16 (App Router), React 19, TypeScript 5 strict, Zod 4, Supabase (Postgres + RLS + auth), Tailwind 4 + shadcn/ui. Vercel-hosted is the primary target; Docker self-hosted must keep working but never at hosted's expense. Path alias `@/*` = repo root. All code, comments, and commits in English.
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
|
||||
The accounting rules are Swedish law, enforced by DB triggers. Code that violates them fails at runtime; code that works around the triggers breaks legal compliance. Never do either.
|
||||
|
||||
1. **Never edit or delete a posted journal entry.** Committed vouchers are immutable. Cancel with `reverseEntry()`; correct with `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`). Storno, never edit.
|
||||
2. **All journal writes go through `lib/bookkeeping/engine.ts`.** Never insert into journal tables directly: voucher numbers are assigned atomically by the `commit_journal_entry` RPC and must stay sequential, and gaps require documented explanations (BFNAR 2013:2, `voucher_gap_explanations`).
|
||||
3. **Every entry balances**: `sum(debits) === sum(credits)`, both `> 0`.
|
||||
4. **Respect period locks.** DB triggers block writes to closed/locked periods and behind the company lock date. Don't work around them: fix the flow that tried to write there.
|
||||
5. **Never delete documents linked to posted entries**: 7-year retention is a legal requirement.
|
||||
6. **Money math is `Math.round(x * 100) / 100`.** Never `toFixed()`: it returns strings and rounds incorrectly, causing öre-level drift that breaks entry balance.
|
||||
7. **Account numbers are strings** (`'1930'`, never `1930`). They are identifiers, not quantities; arithmetic on them is always a bug.
|
||||
|
||||
General prohibitions:
|
||||
|
||||
- **Never modify an existing migration**: schemas already shipped; create a new migration. Never touch the enforcement triggers (migration 017); they are legally required.
|
||||
- **Never leave a remote DB ahead of the repo.** If you `apply_migration` (or run any DDL) against prod, staging, or a preview branch, write the byte-identical SQL into `supabase/migrations/` under the exact applied version in the same change. An applied version with no committed file is an orphan: Supabase branching aborts the next merge to `main` with "Remote migration versions not found in local migrations directory" and blocks every pending migration behind it. The PR preview passes anyway (preview branches fork from prod's history, which already has the orphan), so this only surfaces at merge.
|
||||
- **Core code must never import from `@/extensions/`.** CI builds core with zero extensions enabled; a direct import breaks that build. Extensions cannot use dynamic imports (the registry generates static imports via `setup:extensions`).
|
||||
- **Don't add dependencies without asking.** This is an AGPL-3.0 project; license compatibility matters, and the dependency surface is audited.
|
||||
- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, `gnubok-mcp` npm package. Renaming them breaks live sessions, API keys, and invites.
|
||||
- **Treat `.env.local` as pointing at the production database.** Never run seed/cleanup/repair scripts against it without explicit confirmation.
|
||||
- **Keep the diff scoped to the request.** No drive-by refactors of untouched code.
|
||||
- **Never use em dashes (—) or en dashes (–)** in code, comments, commit messages, or docs. Use a colon, comma, semicolon, or plain hyphen instead, whichever fits the sentence. Exception: a dash character that is the literal subject being parsed, matched, or documented (e.g. mojibake byte-mapping tables, a date-range separator regex) stays as-is; don't launder those into a colon.
|
||||
- Never create a NUL/nul file: `\Accounted\NUL`.
|
||||
|
||||
## When Uncertain
|
||||
|
||||
- **Stop and ask; do not guess.** Especially for anything touching posted entries, the production database, money math, or Swedish tax law.
|
||||
- **Swedish domain questions are never answered from training data.** Load the matching `swedish-*` skill (vat, accounting-compliance, invoice-compliance, payroll, year-end-closing, sie-import-export, sru-filing, financial-reporting, asset-accounting, project-accounting, tax-planning, e-invoicing).
|
||||
- Scaffolding has skills; use them instead of improvising: `/erp-api-route` (API routes), `/supabase-migration` (migrations), `/create-extension` (extensions), `/frontend-design` (new UI), `vercel:deploy` (deployment).
|
||||
|
||||
## Definition of Done
|
||||
|
||||
A change is done when all of these hold; iterate until they do:
|
||||
|
||||
1. `npm run lint` is clean and `npm test` passes (`npx vitest run <dir>` while iterating).
|
||||
2. New or changed logic in `lib/` or `app/api/` has tests: auth 401, validation 400, 404, happy path; mock `@/lib/supabase/server`.
|
||||
3. Any change to a trigger, RPC, RLS policy, or DEFERRABLE constraint ships with a `*.pg.test.ts` (`npm run test:pg`).
|
||||
4. New UI strings exist in **both** `messages/sv.json` and `messages/en.json`.
|
||||
5. If you edited an atom `SKILL.md`, `npm run skills:generate` was run (CI's `skills:check` fails otherwise).
|
||||
6. `npm run check:guards` passes if you touched API routes.
|
||||
7. Commit is conventional (`feat:`/`fix:`/`refactor:`/`test:`/`docs:`), atomic, branched from `main`.
|
||||
8. If the change touches migrations, local and prod are reconciled: every version in prod's `schema_migrations` has a matching file in `supabase/migrations/`, and vice versa. Check before opening the PR (e.g. `list_migrations` / `select version from supabase_migrations.schema_migrations`); a remote-only version means an uncommitted orphan that will fail the merge.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Dev server (runs setup:extensions first)
|
||||
npm run build # Production build (runs setup:extensions first)
|
||||
npm run lint # ESLint
|
||||
npm test # All Vitest tests
|
||||
npx vitest run <dir> # Tests in one directory
|
||||
npm run test:pg # pg-real tests against real Postgres
|
||||
npm run check:guards # Ratchet guard (e.g. no hand-rolled route auth)
|
||||
npm run setup:extensions # Regenerate extension registry from extensions.config.json
|
||||
npm run skills:generate # Regenerate agent_atom_registry seed after editing an atom SKILL.md
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Journal entry lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC); `createJournalEntry()` does both. Everything accounting-shaped routes through this engine.
|
||||
- **Tenancy**: every business table has `company_id`. Active company resolves in `lib/supabase/middleware.ts`: `gnubok-company-id` cookie → `user_preferences.active_company_id` → first membership. RLS uses `user_company_ids()`; queries still filter by `company_id` explicitly (defense in depth: service-role paths have no RLS).
|
||||
- **Auth**: Supabase email+password + TOTP MFA, enforced **application-side**, not in RLS. `NEXT_PUBLIC_REQUIRE_MFA=true` on hosted; `NEXT_PUBLIC_SELF_HOSTED=true` disables MFA. API routes wrap `withRouteContext`: it is the only path that enforces MFA, so never hand-roll `supabase.auth.getUser()` in a route.
|
||||
- **Events**: `lib/events/bus.ts` is a module-level singleton. Any route that emits events must call `ensureInitialized()` (`lib/init.ts`) at module level: otherwise extension handlers are never wired and events silently go nowhere.
|
||||
- **Supabase clients**: browser `client.ts`, server `createClient()`, service role `createServiceClient()`, cookieless service role `createServiceClientNoCookies()` (lives in `lib/auth/api-keys.ts`; for API-key/MCP paths). Paginate with `fetchAllRows()`: PostgREST silently caps at 1000 rows.
|
||||
- **Extensions**: opt-in plugins in `extensions/general/<name>/`; `extensions.config.json` is the source of truth for what's enabled. Core must run with zero extensions.
|
||||
- **MCP server**: the bookkeeping engine is exposed as 100+ MCP tools (`extensions/general/mcp-server/`), authenticated by `gnubok_sk_` API keys (SHA-256, scoped, default 100 RPM per key).
|
||||
- **Types**: import from `@/types` (`types/index.ts`); event types in `lib/events/types.ts`.
|
||||
- **User-facing errors are Swedish**: map through `lib/errors/get-error-message.ts`.
|
||||
- **Cron**: hosted cron jobs live in `vercel.json`, authenticated via `verifyCronSecret()` (`lib/auth/cron.ts`).
|
||||
|
||||
## Repository Map
|
||||
|
||||
- `lib/bookkeeping/`: engine, entry generators, mapping, templates, BAS 2026 data (`bas-data/`)
|
||||
- `lib/core/`: period, year-end, storno, tax codes, audit, documents
|
||||
- `lib/events/`, `lib/auth/`, `lib/supabase/`, `lib/api/` (Zod `validateBody`/`validateQuery`)
|
||||
- `lib/reports/`: balance sheet, income statement, trial balance, GL, ledgers, VAT, SIE, INK2, NE-bilaga, salary, …
|
||||
- `lib/invoices/`, `lib/transactions/`, `lib/import/`, `lib/documents/`, `lib/salary/`, `lib/reconciliation/`, `lib/tax/`, `lib/vat/`, `lib/providers/` (Fortnox/Bokio/Briox/BL/Visma), `lib/skatteverket/`, `lib/currency/`, `lib/bankgiro/`, `lib/deadlines/`, `lib/calendar/`
|
||||
- `lib/utils.ts`: `cn()`, `formatCurrency()`, `formatDate()`, `formatOrgNumber()`; `lib/logger.ts`
|
||||
- `app/(dashboard)/*` pages; `app/api/*` routes; `supabase/migrations/` schema; `extensions/general/*` plugins
|
||||
|
||||
## Testing
|
||||
|
||||
Vitest 4, `node` env, tests in `__tests__/`, scope `lib/` + `app/api/` (no component/E2E tests). Helpers in `tests/helpers.ts`: `createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest()`, `parseJsonResponse()`, plus fixture factories (`makeTransaction`, `makeJournalEntry`, `makeInvoice`, …). `vi.clearAllMocks()` + `eventBus.clear()` in `beforeEach`. Trigger/RPC/RLS behavior is tested in `*.pg.test.ts` against real Postgres, not with mocks.
|
||||
|
||||
## Detail Loads On Demand
|
||||
|
||||
The files below are the shared source of truth for path-specific guidance. Claude Code loads them through their `paths` frontmatter. Codex does not interpret that frontmatter, so before reading, editing, reviewing, or otherwise working with a matching path, read and follow the listed rule. Do not duplicate the rule bodies here.
|
||||
|
||||
- `.claude/rules/design.md`: design system, locked tokens (`app/**`, `components/**`)
|
||||
- `.claude/rules/i18n.md`: sv/en conventions, "stays Swedish" surfaces
|
||||
- `.claude/rules/api-routes.md`: `withRouteContext` route pattern, endpoint map (`app/api/**`)
|
||||
- `.claude/rules/database.md`: migration rules, key tables/RPCs/triggers, pg-real (`supabase/migrations/**`)
|
||||
- `.claude/rules/mcp-server.md`: MCP tool authoring, staged-operation pattern
|
||||
- `.claude/rules/bookkeeping.md`: BAS accounts, VAT treatments/rutor, `lib/core/` services
|
||||
|
||||
## Decision Log
|
||||
|
||||
When you make a non-obvious choice (picked approach A over B, declined a dependency, stopped because a rule here forbade something), append one line to `DECISIONS.md` (repo root): `[YYYY-MM-DD] <decision>: <why>`. Check that file before re-litigating a past decision.
|
||||
@@ -150,4 +150,17 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-13] employee_opening_balances created_by preserved via read-then-upsert, not a DB trigger: a BEFORE UPDATE trigger would need a new migration for a pure audit concern; the extra select is one indexed query and the lock trigger already backstops races.
|
||||
[2026-07-13] Opening balances are authoritative for pre-cutover YTD: runSalaryCalculation now excludes booked runs before the cutover month from the YTD aggregation for employees with opening balances, instead of blocking pre-cutover backdated runs (backfill of history is a supported flow).
|
||||
[2026-07-13] Superseded the 2026-07-13 decline of the NOT VALID suggestion for migration 20260713100000: Emil asked to resolve the PR findings, and the migration is branch-only (verified absent from prod schema_migrations), so the never-modify-shipped-migrations rule does not apply; staging already recorded the versions, so edits only change what prod runs at merge. Implemented as ADD ... NOT VALID in 20260713100000 + 20260713121000 with VALIDATE split into 20260713123000: VALIDATE in the same transaction as ADD would be a no-op since Postgres holds the ACCESS EXCLUSIVE lock until commit; a separate migration file gets its own transaction and validates under SHARE UPDATE EXCLUSIVE. 20260713123000 applied to staging (no-op VALIDATE) and version recorded.
|
||||
[2026-07-14] Codex path-specific guidance stays in the single root AGENTS.md, which dispatches to the shared .claude/rules sources: nested AGENTS.md files were declined to keep one project instruction file, and .codex/config.toml has no Claude-style paths matcher.
|
||||
[2026-07-14] MCP company selection is stateless per tool call with shared API-key scopes: membership and viewer write access are rechecked for the selected company, while per-company scope overrides are deferred to avoid stateful connection races and premature configuration complexity.
|
||||
[2026-07-14] Credit notes are blocked from paid and partially_paid states with early TypeScript guards plus a database CHECK: early guards prevent orphan payment vouchers and provide clear errors, while the constraint protects every remaining RPC and legacy caller. Production had zero existing violations in a count-only audit, so ADD NOT VALID and VALIDATE ship as separate migrations to avoid scanning under the stronger ADD lock.
|
||||
[2026-07-14] Automatic invoice reminder timing uses three strictly increasing company settings from 1 through 365 days, defaulting to 15, 30, and 45: separate columns keep validation and settings forms explicit, while the cron falls back to the legacy schedule if it encounters invalid legacy data.
|
||||
[2026-07-14] Credit notes are created as non-editable but hard-deletable drafts and issue through a compare-and-set plus an idempotent bookkeeping repair path: this preserves the original until the reversing voucher and accrual storno are durable, lets failed drafts be recreated with the same KR number, and blocks the generic MCP invoice executors from bypassing the lifecycle.
|
||||
[2026-07-14] Invoice send actions mention bookkeeping only when issuance creates a journal entry: accrual invoices and ledger-reversing credit notes say send and post, while ordinary cash-method invoices say only send because they are posted at payment.
|
||||
[2026-07-15] Article DELETE performs a company-scoped existence check and blocks when any invoice item references the article before hard deletion; legacy inactive articles remain visible in the register so users can reach and delete them: invoice lines retain frozen accounting values, unused article master data has no retention requirement, and the article-number counter is deliberately not rewound because gaps are harmless.
|
||||
[2026-07-15] Invoice copies start as new drafts and omit dates, lifecycle state, payment links, accrual periods, customer references, and recipient-specific ROT/RUT data: copied invoices must never inherit bookkeeping or stale customer-specific data from the issued original.
|
||||
[2026-07-15] Manual supplier invoice uploads reuse the existing WORM document archive and supplier_invoices.document_id: accrual and privately paid invoices link the document to their registration-time journal entry, while cash-method invoices retain it on the invoice until the existing payment flow links it to the payment entry.
|
||||
[2026-07-15] Supplier payment vouchers surface the supplier invoice PDF as a read-only referenced document instead of moving or duplicating its journal link: the retained source stays on the original registration voucher while the full verification chain remains directly reviewable from the payment voucher.
|
||||
[2026-07-15] Stornoing an opening_balance entry now clears fiscal_periods.opening_balance_entry_id inside reverseEntry, rather than making the year-end gate skip reversed IB entries: a status-aware gate alone is strictly worse, because the close would then proceed to generateOpeningBalances, whose bare UPDATE of opening_balance_entry_id is rejected by enforce_opening_balance_immutability while the old pointer is still set, trading a clear blocker for an opaque Postgres exception after the period is already locked and closed. Clearing at storno time also lets getOpeningBalances fall through to the duplicate-safe compute_prior_opening_balances RPC (it has no status filter and would otherwise keep rendering a cancelled IB), and mirrors the bank-transaction unlink already in reverseEntry. The write is two statements (flag, then pointer) because the trigger reads OLD.opening_balances_set; same order as replace_period_opening_balance_link. Not fixed by refusing to storno a linked IB (the other candidate): the user's goal was to remove a bogus IB so bokslut could re-book it, and the IB-correct flow can only replace, never remove.
|
||||
[2026-07-15] Superseded the hard deletion part of the 2026-07-14 credit note draft decision: numbered credit note drafts are now retained as cancelled rows and reopened on retry so the KR series remains complete.
|
||||
[2026-07-15] Customer personnummer uses application field encryption with masked API and UI output rather than a database-only cipher: the existing key custody and AES-256-GCM implementation can protect values before they reach Postgres, while ordinary reads never expose the full identifier.
|
||||
[2026-07-15] Credit note creation uses a completion marker plus unique company guards instead of a large creation RPC: incomplete parents are never returned, concurrent requests converge, and all journal writes remain in the bookkeeping engine.
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Package,
|
||||
Wrench,
|
||||
Edit2,
|
||||
Archive,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Lock,
|
||||
} from 'lucide-react'
|
||||
@@ -55,6 +55,7 @@ export default function ArticleDetailPage({
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,36 +128,38 @@ export default function ArticleDetailPage({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeactivate() {
|
||||
async function handleDelete() {
|
||||
if (!article) return
|
||||
const ok = await confirmAction({
|
||||
title: t('deactivate_confirm_title', { name: article.name }),
|
||||
description: t('deactivate_confirm_description'),
|
||||
confirmLabel: t('deactivate_confirm_label'),
|
||||
title: t('delete_confirm_title', { name: article.name }),
|
||||
description: t('delete_confirm_description'),
|
||||
confirmLabel: t('delete_confirm_label'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/articles/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Deactivate failed')
|
||||
}
|
||||
await throwOnStructuredError(response)
|
||||
|
||||
toast({
|
||||
title: t('deactivated_title'),
|
||||
title: t('deleted_title'),
|
||||
description: article.name,
|
||||
})
|
||||
router.push('/articles')
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const body = (err as { body?: unknown }).body
|
||||
toast({
|
||||
title: t('deactivate_failed_title'),
|
||||
description: t('retry'),
|
||||
title: t('delete_failed_title'),
|
||||
description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +178,7 @@ export default function ArticleDetailPage({
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
|
||||
<div>
|
||||
<Link
|
||||
href="/articles"
|
||||
@@ -203,7 +206,7 @@ export default function ArticleDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -214,19 +217,23 @@ export default function ArticleDetailPage({
|
||||
{canWrite ? <Edit2 className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
|
||||
{t('edit')}
|
||||
</Button>
|
||||
{article.active && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeactivate}
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? <Archive className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
|
||||
{t('deactivate')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="min-h-10 text-destructive hover:text-destructive"
|
||||
disabled={isDeleting || !canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : canWrite ? (
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -102,7 +102,6 @@ function ArticlesPageInner() {
|
||||
.from('articles')
|
||||
.select('*')
|
||||
.eq('company_id', company.id)
|
||||
.eq('active', true)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number'
|
||||
import CustomerForm from '@/components/customers/CustomerForm'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import {
|
||||
@@ -275,7 +276,7 @@ export default function CustomerDetailPage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business details */}
|
||||
{/* Customer details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('section_business')}</CardTitle>
|
||||
@@ -287,12 +288,20 @@ export default function CustomerDetailPage({
|
||||
{customer.customer_number}
|
||||
</div>
|
||||
)}
|
||||
{customer.org_number && (
|
||||
{customer.customer_type !== 'individual' && customer.org_number && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t('label_org_number')} </span>
|
||||
{customer.org_number}
|
||||
</div>
|
||||
)}
|
||||
{customer.customer_type === 'individual' && (customer.personal_number || customer.org_number) && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t('label_personal_number')} </span>
|
||||
<span className="tabular-nums">
|
||||
{maskCustomerPersonalNumber(customer.personal_number || customer.org_number)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{customer.vat_number && (
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('label_vat')} </span>
|
||||
@@ -306,7 +315,7 @@ export default function CustomerDetailPage({
|
||||
<span className="text-muted-foreground">{t('label_payment_terms')} </span>
|
||||
{t('payment_terms_value', { days: customer.default_payment_terms || 30 })}
|
||||
</div>
|
||||
{!customer.customer_number && !customer.org_number && !customer.vat_number && (
|
||||
{!customer.customer_number && !customer.org_number && !customer.personal_number && !customer.vat_number && (
|
||||
<p className="text-sm text-muted-foreground">{t('no_business_info')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -409,6 +418,7 @@ export default function CustomerDetailPage({
|
||||
country: customer.country || undefined,
|
||||
org_number: customer.org_number || undefined,
|
||||
vat_number: customer.vat_number || undefined,
|
||||
personal_number: customer.personal_number || undefined,
|
||||
default_payment_terms: customer.default_payment_terms || undefined,
|
||||
notes: customer.notes || undefined,
|
||||
}}
|
||||
|
||||
@@ -15,6 +15,10 @@ import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||
import { Loader2, ArrowLeft, AlertTriangle, Lock } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
|
||||
import type { Invoice, InvoiceItem, Customer } from '@/types'
|
||||
|
||||
interface InvoiceWithRelations extends Invoice {
|
||||
@@ -24,6 +28,8 @@ interface InvoiceWithRelations extends Invoice {
|
||||
|
||||
export default function CreateCreditNotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { isSandbox } = useCompany()
|
||||
const canEmail = useCapability(CAPABILITY.email_send)
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -35,6 +41,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [reason, setReason] = useState('')
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [createdCreditNote, setCreatedCreditNote] = useState<InvoiceWithRelations | null>(null)
|
||||
const [showSendPrompt, setShowSendPrompt] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice()
|
||||
@@ -114,14 +122,17 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
throw new Error(data.error || t('create_failed_fallback'))
|
||||
}
|
||||
|
||||
const { data: creditNote } = await response.json()
|
||||
const { data: creditNote } = await response.json() as { data: InvoiceWithRelations }
|
||||
|
||||
toast({
|
||||
title: t('created_toast_title'),
|
||||
description: t('created_toast_description', { number: creditNote.invoice_number }),
|
||||
description: creditNote.invoice_number
|
||||
? t('created_toast_description', { number: creditNote.invoice_number })
|
||||
: undefined,
|
||||
})
|
||||
|
||||
router.push(`/invoices/${creditNote.id}`)
|
||||
setCreatedCreditNote(creditNote)
|
||||
setShowSendPrompt(true)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('create_failed_title'),
|
||||
@@ -146,9 +157,30 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
const customer = invoice.customer
|
||||
const sendMode = getCreditNoteSendMode({
|
||||
customerHasEmail: !!createdCreditNote?.customer.email,
|
||||
isSandbox,
|
||||
canEmail,
|
||||
})
|
||||
|
||||
function handleSendPromptOpenChange(open: boolean) {
|
||||
setShowSendPrompt(open)
|
||||
if (!open && createdCreditNote) {
|
||||
router.push(`/invoices/${createdCreditNote.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl mx-auto">
|
||||
{createdCreditNote && (
|
||||
<SendInvoiceDialog
|
||||
open={showSendPrompt}
|
||||
onOpenChange={handleSendPromptOpenChange}
|
||||
invoice={createdCreditNote}
|
||||
mode={sendMode}
|
||||
onSuccess={() => undefined}
|
||||
/>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
|
||||
|
||||
@@ -15,6 +15,9 @@ import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||
import { invoiceDisplayNumber } from '@/lib/invoices/display'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
|
||||
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
|
||||
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
|
||||
import { canCopyInvoice } from '@/lib/invoices/copy-invoice'
|
||||
import {
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
@@ -33,8 +36,11 @@ import {
|
||||
Lock,
|
||||
CalendarClock,
|
||||
Pencil,
|
||||
Copy,
|
||||
} from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
|
||||
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
|
||||
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
|
||||
@@ -77,6 +83,8 @@ interface InvoiceWithRelations extends Invoice {
|
||||
|
||||
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { isSandbox } = useCompany()
|
||||
const canEmail = useCapability(CAPABILITY.email_send)
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -116,6 +124,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const [nextNumberPreview, setNextNumberPreview] = useState<string | null>(null)
|
||||
const [oreRounding, setOreRounding] = useState<boolean>(true)
|
||||
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
|
||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||
const [reminderDays, setReminderDays] = useState<[number, number, number]>([15, 30, 45])
|
||||
|
||||
const statusLabel = (status: InvoiceStatus): string => t(`status_${status}`)
|
||||
const reminderLevelLabel = (level: 1 | 2 | 3): string => t(`reminder_level_${level}`)
|
||||
@@ -210,21 +220,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
data.company_id
|
||||
? supabase
|
||||
.from('company_settings')
|
||||
.select('ore_rounding, vat_registered')
|
||||
.select('ore_rounding, vat_registered, accounting_method, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
|
||||
.eq('company_id', data.company_id)
|
||||
.maybeSingle()
|
||||
: Promise.resolve(null),
|
||||
data.status === 'credited'
|
||||
!data.credited_invoice_id &&
|
||||
['sent', 'paid', 'overdue', 'credited'].includes(data.status)
|
||||
? supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.select('id, invoice_number, status')
|
||||
.eq('credited_invoice_id', id)
|
||||
.single()
|
||||
.neq('status', 'cancelled')
|
||||
.maybeSingle()
|
||||
: Promise.resolve(null),
|
||||
data.credited_invoice_id
|
||||
? supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
|
||||
.eq('id', data.credited_invoice_id)
|
||||
.single()
|
||||
: Promise.resolve(null),
|
||||
@@ -243,10 +255,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
if (typeof settings?.vat_registered === 'boolean') {
|
||||
setVatRegistered(settings.vat_registered)
|
||||
}
|
||||
setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
|
||||
setReminderDays([
|
||||
settings?.reminder_days_level_1 ?? 15,
|
||||
settings?.reminder_days_level_2 ?? 30,
|
||||
settings?.reminder_days_level_3 ?? 45,
|
||||
])
|
||||
}
|
||||
if (creditNoteRes?.data) {
|
||||
setCreditNote(creditNoteRes.data as Invoice)
|
||||
}
|
||||
setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
|
||||
if (originalRes?.data) {
|
||||
setOriginalInvoice(originalRes.data as Invoice)
|
||||
}
|
||||
@@ -466,7 +482,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
// Unnumbered drafts are hard deleted ("Ta bort"); numbered drafts are
|
||||
// makulerade and keep their number in the series.
|
||||
toast(
|
||||
invoice.invoice_number
|
||||
invoice.invoice_number && !invoice.credited_invoice_id
|
||||
? {
|
||||
title: t('cancelled_toast_title'),
|
||||
description: t('cancelled_with_number', { number: invoice.invoice_number }),
|
||||
@@ -477,7 +493,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
}
|
||||
)
|
||||
|
||||
router.push('/invoices')
|
||||
router.push(
|
||||
invoice.credited_invoice_id
|
||||
? `/invoices/${invoice.credited_invoice_id}`
|
||||
: '/invoices',
|
||||
)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('cancel_failed_title'),
|
||||
@@ -509,6 +529,26 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const isProforma = docType === 'proforma'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isRealInvoice = docType === 'invoice'
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const booksOnIssue = isCreditNote
|
||||
? !!originalInvoice && creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
|
||||
: accountingMethod === 'accrual'
|
||||
const preferredSendMode = getCreditNoteSendMode({
|
||||
customerHasEmail,
|
||||
isSandbox,
|
||||
canEmail,
|
||||
})
|
||||
const creditNoteNeedsRepair =
|
||||
isCreditNote &&
|
||||
invoice.status === 'sent' &&
|
||||
!!originalInvoice &&
|
||||
(
|
||||
originalInvoice.status !== 'credited' ||
|
||||
(
|
||||
creditNoteNeedsJournalEntry(accountingMethod, originalInvoice) &&
|
||||
!invoice.journal_entry_id
|
||||
)
|
||||
)
|
||||
// An unnumbered draft is one saved via "Spara som utkast" that hasn't been
|
||||
// finalized: no F-number yet, so it can still be reviewed-and-created or
|
||||
// hard-deleted. Once finalized it gets a number and behaves like any draft.
|
||||
@@ -525,6 +565,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
// in place (header + lines) via /invoices/{id}/edit. Sent/paid invoices are
|
||||
// immutable (BFL); they are corrected with a credit note instead.
|
||||
const isEditableDraft = isEditableInvoiceDraft(invoice)
|
||||
const isCopyable = canCopyInvoice(invoice)
|
||||
const hasAccruedItems = invoice.items.some(itemHasAccrual)
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -600,14 +641,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === 'draft' && !isDeliveryNote && invoice.invoice_number && (
|
||||
customerHasEmail ? (
|
||||
preferredSendMode === 'email' ? (
|
||||
<Button
|
||||
onClick={() => openSendDialog('email')}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? <Mail className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('send_via_email')}
|
||||
{t(booksOnIssue ? 'send_via_email_and_book' : 'send_via_email')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -617,10 +658,29 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('mark_sent_manually')}
|
||||
{t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{isCopyable && canWrite && (
|
||||
<Link href={`/invoices?copy=${invoice.id}`}>
|
||||
<Button variant="outline">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{t('copy_invoice')}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{creditNoteNeedsRepair && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openSendDialog('manual')}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? <AlertTriangle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('complete_credit_bookkeeping')}
|
||||
</Button>
|
||||
)}
|
||||
{isDeliveryNote && invoice.status === 'draft' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -632,7 +692,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{t('mark_as_sent')}
|
||||
</Button>
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && !isCreditNote && (
|
||||
<Button
|
||||
onClick={() => setShowPaymentDialog(true)}
|
||||
disabled={isUpdating || !canWrite}
|
||||
@@ -1079,7 +1139,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</CardTitle>
|
||||
{reminders.length === 0 && (
|
||||
<CardDescription>
|
||||
{t('reminders_description')}
|
||||
{t('reminders_description', {
|
||||
day1: reminderDays[0],
|
||||
day2: reminderDays[1],
|
||||
day3: reminderDays[2],
|
||||
})}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
@@ -1135,19 +1199,26 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
)}
|
||||
|
||||
{/* Credit note reference (if this invoice was credited) */}
|
||||
{invoice.status === 'credited' && creditNote && (
|
||||
<Card className="border-warning/50">
|
||||
{creditNote && (
|
||||
<Card className={creditNote.status === 'draft' ? undefined : 'border-warning/50'}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-warning">
|
||||
<CardTitle className={cn(
|
||||
'flex items-center gap-2',
|
||||
creditNote.status !== 'draft' && 'text-warning',
|
||||
)}>
|
||||
<ReceiptText className="h-5 w-5" />
|
||||
{t('credited_card_title')}
|
||||
{creditNote.status === 'draft'
|
||||
? t('credit_draft_card_title')
|
||||
: t('credited_card_title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href={`/invoices/${creditNote.id}`}>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('see_credit_note', { number: creditNote.invoice_number ?? '' })}
|
||||
{creditNote.status === 'draft'
|
||||
? t('open_credit_draft', { number: creditNote.invoice_number ?? '' })
|
||||
: t('see_credit_note', { number: creditNote.invoice_number ?? '' })}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
@@ -1201,7 +1272,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
)}
|
||||
|
||||
{/* Status actions */}
|
||||
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && (
|
||||
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && (!invoice.credited_invoice_id || invoice.status === 'draft') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('actions_card_title')}</CardTitle>
|
||||
@@ -1239,7 +1310,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{/* When the customer has an email the header offers "Send via
|
||||
email" as the primary; keep the manual-mark-sent path here
|
||||
as the secondary alternative (it is not in the header). */}
|
||||
{!isDeliveryNote && customerHasEmail && (
|
||||
{!isDeliveryNote && preferredSendMode === 'email' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1247,7 +1318,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
onClick={() => openSendDialog('manual')}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{t('mark_sent_manually')}
|
||||
{t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
|
||||
</Button>
|
||||
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
|
||||
{t('send_manual_hint_with_email')}
|
||||
@@ -1261,12 +1332,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('delete_draft')}
|
||||
{t(isCreditNote ? 'remove_credit_draft' : 'delete_draft')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice) && (
|
||||
{((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice && !creditNote) && (
|
||||
<Link href={`/invoices/${invoice.id}/credit`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ReceiptText className="mr-2 h-4 w-4" />
|
||||
@@ -1280,15 +1351,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove/cancel confirmation. A numbered draft is makulerad (status flips
|
||||
to 'cancelled', number retained for a gap-free series); an unnumbered
|
||||
draft is hard deleted since it never entered the number series. */}
|
||||
{/* Remove/cancel confirmation. An unissued credit-note draft and an
|
||||
unnumbered invoice draft are hard deleted; other numbered drafts are
|
||||
retained as cancelled to preserve their number series. */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{invoice.invoice_number ? t('delete_dialog_title') : t('remove_dialog_title')}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isCreditNote
|
||||
? t('remove_credit_dialog_title')
|
||||
: invoice.invoice_number
|
||||
? t('delete_dialog_title')
|
||||
: t('remove_dialog_title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{invoice.invoice_number ? (
|
||||
{isCreditNote ? (
|
||||
t('remove_credit_dialog_desc')
|
||||
) : invoice.invoice_number ? (
|
||||
<>
|
||||
{t('delete_dialog_desc_with_number_1')}
|
||||
<strong>{t('delete_dialog_status_makulerad')}</strong>
|
||||
@@ -1310,7 +1389,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={deleteInvoice} disabled={isDeleting}>
|
||||
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{invoice.invoice_number ? t('delete_dialog_confirm') : t('remove_dialog_confirm')}
|
||||
{isCreditNote
|
||||
? t('remove_credit_dialog_confirm')
|
||||
: invoice.invoice_number
|
||||
? t('delete_dialog_confirm')
|
||||
: t('remove_dialog_confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -87,7 +87,8 @@ export default function InvoicesPage() {
|
||||
// /invoices/new redirect) opens the same dialog, and the browser back
|
||||
// button closes it. No canWrite gate here: like the old /invoices/new page,
|
||||
// the editor itself disables submission for viewers.
|
||||
const showNewInvoice = searchParams.has('new')
|
||||
const copyFromId = searchParams.get('copy')
|
||||
const showNewInvoice = searchParams.has('new') || copyFromId !== null
|
||||
const closeNewInvoice = () => router.replace('/invoices', { scroll: false })
|
||||
const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false })
|
||||
|
||||
@@ -377,6 +378,7 @@ export default function InvoicesPage() {
|
||||
|
||||
<NewInvoiceDialog
|
||||
open={showNewInvoice}
|
||||
copyFromId={copyFromId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeNewInvoice()
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus, CalendarClock } from 'lucide-react'
|
||||
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus, CalendarClock, Paperclip } from 'lucide-react'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
@@ -22,6 +22,7 @@ import Link from 'next/link'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { DocumentViewButton } from '@/components/bookkeeping/DocumentViewButton'
|
||||
import { formatAmount, formatCurrency } from '@/lib/utils'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
|
||||
@@ -788,6 +789,26 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invoice.document_id && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('document_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Paperclip className="h-4 w-4 shrink-0" />
|
||||
<span>{t('document_attached')}</span>
|
||||
</div>
|
||||
<DocumentViewButton
|
||||
documentId={invoice.document_id}
|
||||
label={t('view_document')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Journal entries (sambandskrav) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function PrivacyPolicyPage() {
|
||||
<ul>
|
||||
<li><strong>Kontouppgifter:</strong> E-postadress (för inloggning)</li>
|
||||
<li><strong>Företagsuppgifter:</strong> Företagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
|
||||
<li><strong>Kundidentitet:</strong> Personnummer för privatkunder när det behövs för avtal eller fakturering</li>
|
||||
<li><strong>Bokföringsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
|
||||
<li><strong>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
|
||||
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokföringsunderlag</li>
|
||||
@@ -203,6 +204,10 @@ export default function PrivacyPolicyPage() {
|
||||
<strong>Kontouppgifter:</strong> Så länge kontot är aktivt, plus 30 dagar efter
|
||||
begäran om radering (för att hantera pågående bokföringsplikter).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kundidentitet:</strong> Under kundrelationen, eller i sju år när uppgiften
|
||||
ingår i räkenskapsinformation som måste bevaras.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tekniska loggar:</strong> Maximalt 90 dagar.
|
||||
</li>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UpdateCustomerSchema } from '@/lib/api/schemas'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'customer.get',
|
||||
@@ -37,7 +38,7 @@ export const GET = withRouteContext(
|
||||
.eq('company_id', companyId)
|
||||
.order('invoice_date', { ascending: false })
|
||||
|
||||
return NextResponse.json({ data: { ...data, invoices: invoices || [] } })
|
||||
return NextResponse.json({ data: { ...maskCustomerRow(data), invoices: invoices || [] } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -55,6 +56,26 @@ export const PATCH = withRouteContext(
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, customer_type')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (existingError || !existing) {
|
||||
if (existingError?.code === 'PGRST116') {
|
||||
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
opLog.error('customer lookup before update failed', existingError)
|
||||
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
const effectiveType = body.customer_type ?? existing.customer_type
|
||||
if (body.personal_number && effectiveType !== 'individual') {
|
||||
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId })
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.name !== undefined) updateData.name = body.name
|
||||
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
|
||||
@@ -69,6 +90,11 @@ export const PATCH = withRouteContext(
|
||||
if (body.country !== undefined) updateData.country = body.country
|
||||
if (body.org_number !== undefined) updateData.org_number = body.org_number
|
||||
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
|
||||
if (body.personal_number !== undefined) {
|
||||
updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number)
|
||||
} else if (body.customer_type !== undefined && effectiveType !== 'individual') {
|
||||
updateData.personal_number = null
|
||||
}
|
||||
if (body.language !== undefined) updateData.language = body.language
|
||||
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
@@ -82,6 +108,9 @@ export const PATCH = withRouteContext(
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
if (error.code === '23505') {
|
||||
return errorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', opLog, {
|
||||
requestId,
|
||||
@@ -127,7 +156,7 @@ export const PATCH = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
return NextResponse.json({ data: maskCustomerRow(data) })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
|
||||
let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
|
||||
|
||||
const buildChain = (): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (value: unknown) => void) => resolve(queryResult)
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (prop === 'insert') captured.insert.push(args[0])
|
||||
if (prop === 'update') captured.update.push(args[0])
|
||||
return buildChain()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn(() => buildChain()),
|
||||
rpc: vi.fn(() => buildChain()),
|
||||
}
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { PATCH } from '../[id]/route'
|
||||
|
||||
type CustomerWrite = { personal_number?: string | null }
|
||||
|
||||
describe('personal_number on customer routes', () => {
|
||||
const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
captured.insert.length = 0
|
||||
captured.update.length = 0
|
||||
queryResult = { data: null, error: null }
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 before creating a customer when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: { name: 'Anna Andersson', customer_type: 'individual' },
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(captured.insert).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 400 for an invalid personal number', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: 'not-a-personal-number',
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(captured.insert).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('stores the personal number when creating a private customer', async () => {
|
||||
queryResult = {
|
||||
data: {
|
||||
id: 'customer-1',
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: '19900101-1234',
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: '19900101-1234',
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string
|
||||
expect(encrypted).not.toBe('19900101-1234')
|
||||
expect(decryptPersonnummer(encrypted)).toBe('19900101-1234')
|
||||
expect(body.data.personal_number).toBe('********-1234')
|
||||
})
|
||||
|
||||
it('updates the personal number for an existing private customer', async () => {
|
||||
queryResult = {
|
||||
data: {
|
||||
id: 'customer-1',
|
||||
customer_type: 'individual',
|
||||
personal_number: '900101-1234',
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: '900101-1234' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const encrypted = (captured.update[0] as CustomerWrite).personal_number as string
|
||||
expect(encrypted).not.toBe('900101-1234')
|
||||
expect(decryptPersonnummer(encrypted)).toBe('900101-1234')
|
||||
})
|
||||
|
||||
it('clears the personal number when null is sent', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'individual', personal_number: null },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: null },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect((captured.update[0] as CustomerWrite).personal_number).toBeNull()
|
||||
})
|
||||
|
||||
it('does not change the personal number when the field is omitted', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'Anna A' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(captured.update[0]).not.toHaveProperty('personal_number')
|
||||
})
|
||||
|
||||
it('rejects a personal number for a corporate customer', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'swedish_business' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: '900101-1234' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED')
|
||||
expect(captured.update).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 404 when the customer does not exist', async () => {
|
||||
queryResult = {
|
||||
data: null,
|
||||
error: { code: 'PGRST116', message: 'No rows returned' },
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/missing', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: '900101-1234' },
|
||||
}),
|
||||
{ params: Promise.resolve({ id: 'missing' }) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -18,13 +18,14 @@ vi.mock('@/lib/init', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
import { DELETE, PATCH } from '../route'
|
||||
|
||||
describe('DELETE /api/invoices/[id]', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
@@ -175,3 +176,53 @@ describe('DELETE /api/invoices/[id]', () => {
|
||||
expect(status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/invoices/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1', email: 'test@test.se' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects editing a credit-note draft', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'credit-1',
|
||||
status: 'draft',
|
||||
invoice_number: 'KR-F-2026001',
|
||||
journal_entry_id: null,
|
||||
is_self_billed: false,
|
||||
credited_invoice_id: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/invoices/credit-1', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
customer_id: '22222222-2222-4222-8222-222222222222',
|
||||
invoice_date: '2026-07-14',
|
||||
due_date: '2026-07-14',
|
||||
currency: 'SEK',
|
||||
items: [
|
||||
{
|
||||
description: 'Kredit',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createMockRouteParams({ id: 'credit-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +114,42 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a sent credit note before booking a payment', async () => {
|
||||
const invoice = makeInvoice({
|
||||
status: 'sent',
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
})
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an original invoice while an active credit-note draft exists', async () => {
|
||||
const invoice = {
|
||||
...makeInvoice({ status: 'sent', credited_invoice_id: null }),
|
||||
credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }],
|
||||
}
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'inv-1' }),
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks sent invoice as paid with accrual method', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
|
||||
@@ -31,7 +31,7 @@ export const POST = withRouteContext(
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -40,6 +40,26 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
if (invoice.credited_invoice_id) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
|
||||
requestId,
|
||||
details: { reason: 'credit_note' },
|
||||
})
|
||||
}
|
||||
|
||||
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> }).credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (activeCreditNotes.length > 0) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
|
||||
requestId,
|
||||
details: { reason: 'active_credit_note' },
|
||||
})
|
||||
}
|
||||
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
|
||||
requestId,
|
||||
|
||||
@@ -52,6 +52,17 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockIssueCreditNote = vi.fn()
|
||||
vi.mock('@/lib/invoices/issue-credit-note', () => ({
|
||||
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
|
||||
creditNoteNeedsJournalEntry: (method: string, original: { status: string; journal_entry_id?: string | null; paid_at?: string | null; paid_amount?: number | null }) =>
|
||||
method === 'accrual' ||
|
||||
!!original.journal_entry_id ||
|
||||
original.status === 'paid' ||
|
||||
!!original.paid_at ||
|
||||
Math.abs(original.paid_amount ?? 0) > 0,
|
||||
}))
|
||||
|
||||
const mockUploadDocument = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
|
||||
@@ -94,18 +105,66 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
||||
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
||||
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
||||
mockIssueCreditNote.mockResolvedValue({
|
||||
complete: true,
|
||||
journalEntryId: 'credit-je-1',
|
||||
journalEntryRequired: true,
|
||||
failures: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the invoice does not exist', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/missing/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'missing' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 400 when the invoice is not a draft', async () => {
|
||||
enqueue({ data: makeInvoice({ id: 'inv-1', status: 'sent' }), error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('archives the rendered PDF as underlag linked to the journal entry', async () => {
|
||||
enqueue({ data: invoice, error: null }) // fetch invoice
|
||||
enqueue({ data: null, error: null }) // status update
|
||||
enqueue({ data: company, error: null }) // settings
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-7' })
|
||||
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string | null
|
||||
partial?: boolean
|
||||
partial_failures?: Array<{ step: string }>
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
@@ -128,37 +187,25 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('archives the PDF even when journal entry creation fails (non-blocking)', async () => {
|
||||
it('restores the draft and fails closed when journal entry creation fails', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBeNull()
|
||||
|
||||
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
|
||||
expect(mockUploadDocument).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'company-1',
|
||||
expect.objectContaining({ name: 'faktura-F-2026010.pdf' }),
|
||||
expect.objectContaining({
|
||||
upload_source: 'system',
|
||||
journal_entry_id: undefined,
|
||||
})
|
||||
)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('INVOICE_MARK_SENT_BOOK_FAILED')
|
||||
expect(mockUploadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still returns 200 when PDF archival itself fails', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-8' })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -183,8 +230,8 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: proforma, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: [{ id: 'inv-2' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-2/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-2' }))
|
||||
@@ -196,10 +243,10 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
expect(mockRenderToBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses kreditfaktura filename when archiving a credit note', async () => {
|
||||
it('issues the credit note and uses a credit-note filename when archiving it', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'inv-3',
|
||||
invoice_number: 'F-2026011',
|
||||
invoice_number: 'KR-F-2026010',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
@@ -207,31 +254,132 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-9' })
|
||||
enqueue({ data: null, error: null })
|
||||
// Lookup of the original invoice's number for the credit note PDF
|
||||
enqueue({ data: { invoice_number: 'F-2026010' }, error: null })
|
||||
const original = {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026010',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
}
|
||||
enqueue({ data: original, error: null })
|
||||
enqueue({ data: [{ id: 'inv-3' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-3/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-3' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockIssueCreditNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
creditNote: expect.objectContaining({ id: 'inv-3' }),
|
||||
originalInvoice: original,
|
||||
accountingMethod: 'accrual',
|
||||
}),
|
||||
)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockUploadDocument).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'company-1',
|
||||
expect.objectContaining({ name: 'kreditfaktura-F-2026011.pdf' }),
|
||||
expect.objectContaining({ name: 'kreditfaktura-KR-F-2026010.pdf' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed and restores the draft when credit-note booking cannot start', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2026010',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026010',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
mockIssueCreditNote.mockResolvedValue({
|
||||
complete: false,
|
||||
journalEntryId: null,
|
||||
journalEntryRequired: true,
|
||||
failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }],
|
||||
})
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(mockUploadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repairs a sent credit note without running the draft status transition again', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2026010',
|
||||
status: 'sent',
|
||||
credited_invoice_id: 'inv-1',
|
||||
journal_entry_id: null,
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026010',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockIssueCreditNote).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns 409 when another request already marked the draft as sent', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockUploadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the archived PDF as if already sent (no UTKAST banner)', async () => {
|
||||
enqueue({ data: invoice, error: null }) // fetch invoice (status: 'draft')
|
||||
enqueue({ data: null, error: null }) // status update
|
||||
enqueue({ data: company, error: null }) // settings
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-99' })
|
||||
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateInvoiceSchema } from '@/lib/api/schemas'
|
||||
@@ -15,8 +11,6 @@ import type { InvoiceDocumentType } from '@/types'
|
||||
|
||||
ensureInitialized() // Module-level: wires the audit-log handler for invoice.draft_deleted.
|
||||
|
||||
const log = createLogger('api.invoices.cancel')
|
||||
|
||||
/**
|
||||
* DELETE /api/invoices/[id]
|
||||
*
|
||||
@@ -32,37 +26,25 @@ const log = createLogger('api.invoices.cancel')
|
||||
* Only drafts may be removed either way. Sent / paid invoices are immutable per
|
||||
* BFL and must be reversed via a credit note instead.
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.delete',
|
||||
async (_request, { user, supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
const opLog = log.child({ invoiceId: id })
|
||||
|
||||
const { data: invoice, error: fetchError } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, status, invoice_number, user_id')
|
||||
.select('id, status, invoice_number, user_id, credited_invoice_id, journal_entry_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !invoice) {
|
||||
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
|
||||
return errorResponseFromCode('INVOICE_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
if (invoice.status !== 'draft') {
|
||||
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log)
|
||||
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Unnumbered drafts (saved via "Spara som utkast", never finalized) are not
|
||||
@@ -82,13 +64,14 @@ export async function DELETE(
|
||||
.select('id')
|
||||
|
||||
if (removeError) {
|
||||
return NextResponse.json({ error: removeError.message }, { status: 500 })
|
||||
opLog.error('invoice draft delete failed', removeError)
|
||||
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
if (!removed || removed.length === 0) {
|
||||
// Finalized between fetch and delete: refuse rather than fall through to
|
||||
// makulering of a now-issued invoice.
|
||||
return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
|
||||
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
|
||||
}
|
||||
|
||||
// The row is gone, so there's no journal trace of the removal. Emit an
|
||||
@@ -100,7 +83,7 @@ export async function DELETE(
|
||||
payload: { invoiceId: id, companyId, userId: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
}
|
||||
|
||||
// Numbered draft: retain the row and its number, flip to 'cancelled'
|
||||
@@ -118,15 +101,18 @@ export async function DELETE(
|
||||
.select('id')
|
||||
|
||||
if (cancelError) {
|
||||
return NextResponse.json({ error: cancelError.message }, { status: 500 })
|
||||
opLog.error('invoice cancellation failed', cancelError)
|
||||
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
if (!updated || updated.length === 0) {
|
||||
return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
|
||||
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
|
||||
}
|
||||
return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH /api/invoices/[id]
|
||||
@@ -161,7 +147,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// received self-billing document) may be edited.
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, status, invoice_number, journal_entry_id, is_self_billed')
|
||||
.select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId!)
|
||||
.single()
|
||||
|
||||
@@ -66,6 +66,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockIssueCreditNote = vi.fn()
|
||||
vi.mock('@/lib/invoices/issue-credit-note', () => ({
|
||||
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
|
||||
}))
|
||||
|
||||
// The sandbox guard issues a company_settings query at the top of the route;
|
||||
// short-circuit it in tests since the queued mock-supabase is shaped for the
|
||||
// route's existing fetch chain, not an extra pre-flight read.
|
||||
@@ -113,6 +118,12 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockIsConfigured.mockReturnValue(true)
|
||||
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
||||
mockIssueCreditNote.mockResolvedValue({
|
||||
complete: true,
|
||||
journalEntryId: 'credit-je-1',
|
||||
journalEntryRequired: true,
|
||||
failures: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
@@ -314,6 +325,144 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('issues and books a credit-note draft through the email send flow', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
items: (invoice.items ?? []).map((item) => ({
|
||||
...item,
|
||||
invoice_id: 'credit-1',
|
||||
quantity: -Math.abs(item.quantity),
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_amount: -Math.abs(item.vat_amount ?? 0),
|
||||
})),
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
total: -12500,
|
||||
})
|
||||
const original = {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2024001',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
}
|
||||
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: original, error: null })
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'credit-message-1' })
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; message: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.message).toContain('Kreditfakturan har skickats')
|
||||
expect(mockIssueCreditNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: 'company-1',
|
||||
creditNote: expect.objectContaining({ id: 'credit-1' }),
|
||||
originalInvoice: original,
|
||||
accountingMethod: 'accrual',
|
||||
}),
|
||||
)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
expect.objectContaining({ filename: 'kreditfaktura-KR-F-2024001.pdf' }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(emitSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'invoice.sent' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not email a credit note when its bookkeeping cannot be completed', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2024001',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
mockIssueCreditNote.mockResolvedValue({
|
||||
complete: false,
|
||||
journalEntryId: null,
|
||||
journalEntryRequired: true,
|
||||
failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }],
|
||||
})
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries delivery for an already-issued credit note after provider failure', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'sent',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2024001',
|
||||
status: 'credited',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'retry-message-1' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/invoices/credit-1/send', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'credit-1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockIssueCreditNote).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendEmail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips journal entry for cash method', async () => {
|
||||
const cashCompany = makeCompanySettings({ accounting_method: 'cash' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
@@ -451,12 +600,10 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
// Provider errors map to 502 PROVIDER_FAILED with the provider message in details.
|
||||
// Provider errors map to a safe retryable response without leaking provider text.
|
||||
expect(status).toBe(502)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_PROVIDER_FAILED')
|
||||
expect(
|
||||
(body.error as unknown as { details?: { providerError?: string } }).details?.providerError,
|
||||
).toContain('SMTP error')
|
||||
expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the final PDF as if already sent (no UTKAST banner)', async () => {
|
||||
|
||||
@@ -14,13 +14,25 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import {
|
||||
issueCreditNote,
|
||||
type CreditNoteOriginalInvoice,
|
||||
} from '@/lib/invoices/issue-credit-note'
|
||||
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
import type {
|
||||
AccountingMethod,
|
||||
CompanySettings,
|
||||
CreditNote,
|
||||
Customer,
|
||||
EntityType,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
} from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -59,6 +71,9 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const isCreditDeliveryRetry = isCreditNote && invoice.status === 'sent'
|
||||
|
||||
// A cancelled invoice keeps its F-series number for compliance with ML 17
|
||||
// kap 24§ but is not a valid faktura: sending it would deliver a
|
||||
// "MAKULERAD" PDF as if it were live. Checked before the generic draft
|
||||
@@ -73,7 +88,7 @@ export const POST = withRouteContext(
|
||||
// (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
|
||||
// and orphaning the first entry. Mirrors the v1 route and the MCP commit
|
||||
// executor, which both reject non-drafts.
|
||||
if (invoice.status !== 'draft') {
|
||||
if (invoice.status !== 'draft' && !isCreditDeliveryRetry) {
|
||||
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
|
||||
requestId,
|
||||
details: { currentStatus: invoice.status },
|
||||
@@ -100,18 +115,22 @@ export const POST = withRouteContext(
|
||||
|
||||
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
let originalInvoice: CreditNoteOriginalInvoice | undefined
|
||||
let originalInvoiceNumber: string | undefined
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: originalInvoice } = await supabase
|
||||
const { data: original } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (originalInvoice) {
|
||||
originalInvoiceNumber = originalInvoice.invoice_number
|
||||
if (!original) {
|
||||
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
originalInvoice = original as CreditNoteOriginalInvoice
|
||||
originalInvoiceNumber = original.invoice_number ?? undefined
|
||||
}
|
||||
|
||||
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
|
||||
@@ -149,13 +168,15 @@ export const POST = withRouteContext(
|
||||
// that the number exists, so the email button and PDF QR carry it. A
|
||||
// failure never blocks the send: the faktura is legally valid without a
|
||||
// link, so it degrades to a PARTIAL warning instead.
|
||||
const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
opLog,
|
||||
)
|
||||
const { failure: paymentLinkFailure } = isCreditNote
|
||||
? { failure: undefined }
|
||||
: await applyPaymentLinkToInvoice(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
opLog,
|
||||
)
|
||||
|
||||
// Final render with the assigned number: this is the buffer attached to
|
||||
// the email and later archived as underlag. Override status to 'sent' on
|
||||
@@ -182,12 +203,11 @@ export const POST = withRouteContext(
|
||||
)
|
||||
|
||||
const emailData = {
|
||||
invoice: invoice as Invoice,
|
||||
invoice: renderableInvoice,
|
||||
customer,
|
||||
company: company as CompanySettings,
|
||||
}
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = invoice.document_type || 'invoice'
|
||||
let filename: string
|
||||
if (isCreditNote) {
|
||||
@@ -201,6 +221,78 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
const ccAddress = company.email || user.email
|
||||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||||
if (paymentLinkFailure) {
|
||||
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
|
||||
}
|
||||
|
||||
let statusFlipped = isCreditDeliveryRetry
|
||||
let creditJournalEntryId: string | null = null
|
||||
|
||||
// Credit notes must be fully issued and booked before delivery. The CAS is
|
||||
// the single-winner lock; the idempotent issue service can repair any
|
||||
// immutable entry that committed before a later database step failed.
|
||||
if (isCreditNote && originalInvoice) {
|
||||
if (!isCreditDeliveryRetry) {
|
||||
const { data: flipRows, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
opLog.error('credit note status update failed before issue', updateError)
|
||||
return errorResponseFromCode('INVOICE_CREDIT_ISSUE_INCOMPLETE', opLog, {
|
||||
requestId,
|
||||
details: { failure_steps: ['status_update'] },
|
||||
})
|
||||
}
|
||||
if (!flipRows || flipRows.length === 0) {
|
||||
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
|
||||
requestId,
|
||||
details: { currentStatus: 'sent' },
|
||||
})
|
||||
}
|
||||
statusFlipped = true
|
||||
}
|
||||
|
||||
const issueResult = await issueCreditNote({
|
||||
supabase,
|
||||
companyId: companyId!,
|
||||
userId: user.id,
|
||||
creditNote: invoice as CreditNote,
|
||||
originalInvoice,
|
||||
entityType: ((company as CompanySettings).entity_type as EntityType) || 'enskild_firma',
|
||||
accountingMethod: ((company as Record<string, unknown>).accounting_method || 'accrual') as AccountingMethod,
|
||||
log: opLog,
|
||||
})
|
||||
creditJournalEntryId = issueResult.journalEntryId
|
||||
|
||||
if (!issueResult.complete) {
|
||||
if (issueResult.journalEntryRequired && !issueResult.journalEntryId) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'draft' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'sent')
|
||||
.is('journal_entry_id', null)
|
||||
}
|
||||
return errorResponseFromCode(
|
||||
issueResult.repairRequired
|
||||
? 'INVOICE_CREDIT_REPAIR_REQUIRED'
|
||||
: 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
|
||||
opLog,
|
||||
{
|
||||
requestId,
|
||||
details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: customer.email,
|
||||
cc: ccAddress,
|
||||
@@ -222,7 +314,7 @@ export const POST = withRouteContext(
|
||||
opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown'))
|
||||
return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { providerError: result.error },
|
||||
details: { retryable: true },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -230,12 +322,6 @@ export const POST = withRouteContext(
|
||||
// follow-up steps degrade the response to PARTIAL: the user gets a
|
||||
// success toast with a sub-warning, and the audit trail records exactly
|
||||
// which sub-step broke.
|
||||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||||
|
||||
if (paymentLinkFailure) {
|
||||
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
|
||||
}
|
||||
|
||||
// Optimistic-locked flip (draft → sent). Two concurrent sends can both
|
||||
// pass the draft guard above and both email the customer, but only the
|
||||
// request that wins this compare-and-set runs the bookkeeping steps
|
||||
@@ -245,8 +331,7 @@ export const POST = withRouteContext(
|
||||
// A genuine update error also skips the follow-ups: the row is still
|
||||
// 'draft', so a later retry re-runs the whole pipeline and ends with
|
||||
// exactly one journal entry (at the cost of a duplicate email).
|
||||
let statusFlipped = false
|
||||
{
|
||||
if (!isCreditNote) {
|
||||
const { data: flipRows, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
@@ -270,10 +355,10 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
const accountingMethod = (company as Record<string, unknown>).accounting_method as string | undefined
|
||||
let createdJournalEntryId: string | undefined
|
||||
const accountingMethod = ((company as Record<string, unknown>).accounting_method || 'accrual') as AccountingMethod
|
||||
let createdJournalEntryId: string | undefined = creditJournalEntryId ?? undefined
|
||||
|
||||
if (statusFlipped && isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
|
||||
if (statusFlipped && !isCreditNote && isRealInvoice && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
@@ -312,7 +397,7 @@ export const POST = withRouteContext(
|
||||
opLog.error('failed to create invoice journal entry on send', err as Error)
|
||||
partialFailures.push({
|
||||
step: 'journal_entry',
|
||||
reason: err instanceof Error ? err.message : 'unknown',
|
||||
reason: 'Fakturans verifikat kunde inte skapas.',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -332,7 +417,7 @@ export const POST = withRouteContext(
|
||||
opLog.error('failed to store invoice PDF as underlag', err as Error)
|
||||
partialFailures.push({
|
||||
step: 'pdf_archive',
|
||||
reason: err instanceof Error ? err.message : 'unknown',
|
||||
reason: 'Fakturans PDF kunde inte arkiveras.',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -340,7 +425,7 @@ export const POST = withRouteContext(
|
||||
// Gated like the steps above: on a lost race the winning request emits
|
||||
// it; on a flip error the row is still 'draft', so emitting would
|
||||
// contradict DB state and the retry emits it instead.
|
||||
if (statusFlipped) {
|
||||
if (statusFlipped && !isCreditNote) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
|
||||
@@ -356,7 +441,7 @@ export const POST = withRouteContext(
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Fakturan har skickats till ${customer.email} (kopia till ${ccAddress})`,
|
||||
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`,
|
||||
messageId: result.messageId,
|
||||
...(partialFailures.length > 0
|
||||
? { partial: true, partial_failures: partialFailures }
|
||||
|
||||
@@ -41,12 +41,6 @@ vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
convertToSEK: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateCreditNoteJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createCreditNoteJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateCreditNoteJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
describe('GET /api/invoices', () => {
|
||||
@@ -419,7 +413,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
|
||||
})
|
||||
|
||||
it('creates credit note with negated amounts and emits event', async () => {
|
||||
it('creates a credit note draft without booking or crediting the original', async () => {
|
||||
const items = [
|
||||
{
|
||||
id: 'item-1',
|
||||
@@ -449,25 +443,21 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
total: -12500,
|
||||
status: 'sent',
|
||||
status: 'draft',
|
||||
})
|
||||
|
||||
// Fetch original invoice
|
||||
enqueue({ data: original, error: null })
|
||||
// No existing credit-note draft
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert credit note
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Insert credit note items
|
||||
enqueue({ data: null, error: null })
|
||||
// Update original status to 'credited'
|
||||
// Mark creation complete
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch complete credit note
|
||||
enqueue({ data: { ...creditNote, items: [] }, error: null })
|
||||
// Fetch company settings for entity type
|
||||
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
// Update credit note with journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
@@ -476,13 +466,35 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toBeTruthy()
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'credit_note.created' })
|
||||
)
|
||||
expect(body.data.status).toBe('draft')
|
||||
expect(emitSpy).not.toHaveBeenCalled()
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('returns an existing credit-note draft instead of creating a duplicate', async () => {
|
||||
const original = makeInvoice({ id: VALID_UUID, status: 'sent' })
|
||||
const existing = makeInvoice({
|
||||
id: 'credit-existing',
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'draft',
|
||||
credited_invoice_id: VALID_UUID,
|
||||
})
|
||||
enqueue({ data: original, error: null })
|
||||
enqueue({ data: existing, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('credit-existing')
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rolls back credit note when items insertion fails', async () => {
|
||||
@@ -508,6 +520,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
const creditNote = makeInvoice({ id: 'cn-1' })
|
||||
|
||||
enqueue({ data: original, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Items fail
|
||||
enqueue({ data: null, error: { message: 'Items insert failed' } })
|
||||
|
||||
+107
-115
@@ -3,11 +3,10 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas'
|
||||
import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
|
||||
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
|
||||
import type { Invoice, InvoiceDocumentType, InvoiceItem } from '@/types'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
|
||||
import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
@@ -253,10 +252,6 @@ async function createCreditNote(
|
||||
log: Logger,
|
||||
requestId: string,
|
||||
) {
|
||||
// Non-blocking issues (e.g. partial accrual cancellation) surfaced to the
|
||||
// caller alongside the created credit note.
|
||||
const warnings: Array<{ code: string; message: string }> = []
|
||||
|
||||
const { data: originalInvoice, error: originalError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
@@ -286,6 +281,59 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
// Returning the existing credit note makes the action idempotent. A
|
||||
// cancelled, unissued draft is reopened so the deterministic KR number can
|
||||
// be reused without colliding with the company-wide invoice-number key.
|
||||
const { data: existingCreditNote, error: existingCreditNoteError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('credited_invoice_id', input.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', true)
|
||||
.maybeSingle()
|
||||
|
||||
if (existingCreditNoteError) {
|
||||
log.error('failed to check for an existing credit note', existingCreditNoteError)
|
||||
return errorResponse(existingCreditNoteError, log, { requestId })
|
||||
}
|
||||
if (existingCreditNote) {
|
||||
if (existingCreditNote.status === 'cancelled' && !existingCreditNote.journal_entry_id) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const { error: reopenError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'draft',
|
||||
invoice_date: today,
|
||||
due_date: today,
|
||||
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', existingCreditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'cancelled')
|
||||
|
||||
if (reopenError) {
|
||||
log.error('failed to reopen cancelled credit note draft', reopenError)
|
||||
return errorResponse(reopenError, log, { requestId })
|
||||
}
|
||||
|
||||
const { data: reopenedCreditNote, error: reopenedError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', existingCreditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (reopenedError || !reopenedCreditNote) {
|
||||
return errorResponse(reopenedError ?? new Error('Credit note draft not found'), log, {
|
||||
requestId,
|
||||
})
|
||||
}
|
||||
return NextResponse.json({ data: reopenedCreditNote })
|
||||
}
|
||||
return NextResponse.json({ data: existingCreditNote })
|
||||
}
|
||||
|
||||
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
|
||||
|
||||
const { data: creditNote, error: creditNoteError } = await supabase
|
||||
@@ -313,17 +361,33 @@ async function createCreditNote(
|
||||
reverse_charge_text: originalInvoice.reverse_charge_text,
|
||||
your_reference: originalInvoice.your_reference,
|
||||
our_reference: originalInvoice.our_reference,
|
||||
deduction_total: originalInvoice.deduction_total
|
||||
? -Math.abs(originalInvoice.deduction_total)
|
||||
: 0,
|
||||
deduction_personnummer_encrypted: originalInvoice.deduction_personnummer_encrypted ?? null,
|
||||
deduction_personnummer_last4: originalInvoice.deduction_personnummer_last4 ?? null,
|
||||
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
||||
credited_invoice_id: input.credited_invoice_id,
|
||||
// Copy the original's dimension bag so the credit-note verifikat nets
|
||||
// against the same dimension cells in reports (dimensions PR7).
|
||||
default_dimensions: originalInvoice.default_dimensions ?? {},
|
||||
status: 'sent',
|
||||
status: 'draft',
|
||||
creation_complete: false,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (creditNoteError) {
|
||||
if (creditNoteError.code === '23505') {
|
||||
const { data: racedCreditNote } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('credited_invoice_id', input.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', true)
|
||||
.maybeSingle()
|
||||
if (racedCreditNote) return NextResponse.json({ data: racedCreditNote })
|
||||
}
|
||||
log.error('credit note insert failed', creditNoteError)
|
||||
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
||||
requestId,
|
||||
@@ -331,40 +395,24 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null; dimensions?: Record<string, string> }) => ({
|
||||
invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
description: item.description,
|
||||
quantity: -Math.abs(item.quantity),
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_rate: item.vat_rate ?? 0,
|
||||
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
||||
// Carry the original's per-line revenue-account override so the reversal
|
||||
// hits the SAME account it originally credited (e.g. 3041, not the
|
||||
// VAT-derived 3001): otherwise the override account keeps a dangling
|
||||
// balance. article_id is preserved for the usage history.
|
||||
revenue_account: item.revenue_account ?? null,
|
||||
article_id: item.article_id ?? null,
|
||||
// Same reasoning for periodiserade lines: the credit-note verifikat must
|
||||
// reverse against the 29xx interim account the original credited, not the
|
||||
// revenue account. generatePerRateLines reads these fields to substitute.
|
||||
// No schedule is ever created for a credit note (only send/mark-sent
|
||||
// create schedules); the original's schedule is cancelled below.
|
||||
accrual_period_start: item.accrual_period_start ?? null,
|
||||
accrual_period_end: item.accrual_period_end ?? null,
|
||||
accrual_balance_account: item.accrual_balance_account ?? null,
|
||||
// Same reasoning as revenue_account: the reversal must carry the exact
|
||||
// per-item bag the original booked with (dimensions PR7).
|
||||
dimensions: item.dimensions ?? {},
|
||||
}))
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: InvoiceItem) =>
|
||||
buildCreditNoteItem(creditNote.id, item)
|
||||
)
|
||||
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
|
||||
|
||||
if (itemsError) {
|
||||
await supabase.from('invoices').delete().eq('id', creditNote.id)
|
||||
const { error: cleanupError } = await supabase
|
||||
.from('invoices')
|
||||
.delete()
|
||||
.eq('id', creditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', false)
|
||||
if (cleanupError) {
|
||||
log.error('failed to clean up incomplete credit note', cleanupError, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
}
|
||||
log.error('credit note items insert failed; rolled back', itemsError, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
@@ -374,91 +422,35 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
await supabase
|
||||
const { error: completionError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'credited' })
|
||||
.eq('id', input.credited_invoice_id)
|
||||
.update({ creation_complete: true, updated_at: new Date().toISOString() })
|
||||
.eq('id', creditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', false)
|
||||
|
||||
const { data: completeCreditNote } = await supabase
|
||||
if (completionError) {
|
||||
log.error('failed to mark credit note creation complete', completionError, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: completeCreditNote, error: completeCreditNoteError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', creditNote.id)
|
||||
.single()
|
||||
|
||||
const { data: creditNoteSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, accounting_method')
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', true)
|
||||
.single()
|
||||
|
||||
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual'
|
||||
|
||||
// Cash method skips: there's no original invoice JE to reverse, recognition
|
||||
// is deferred until refund.
|
||||
if (completeCreditNote && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
completeCreditNote as Invoice,
|
||||
entityType,
|
||||
completeCreditNote.customer?.name,
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', creditNote.id)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('failed to create credit note journal entry', err as Error, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
// Non-blocking: credit note still exists.
|
||||
}
|
||||
|
||||
// Periodisering interplay: cancel remaining months and storno posted
|
||||
// dissolutions so origin + dissolutions + stornos + credit net to zero on
|
||||
// both 29xx and 3xxx. Best-effort: never blocks the credit itself, but
|
||||
// partial reversals are surfaced as a response warning so the user knows
|
||||
// the schedule stayed active.
|
||||
try {
|
||||
const cancelResult = await cancelSchedulesForSource(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
{ invoiceId: input.credited_invoice_id },
|
||||
{ reversalDate: creditNote.invoice_date },
|
||||
)
|
||||
if (cancelResult.failedReversals > 0) {
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_CANCEL_PARTIAL',
|
||||
message:
|
||||
'Fakturan krediterades, men en eller flera periodiseringsverifikat ' +
|
||||
'kunde inte vändas. Periodiseringen är fortfarande aktiv: ' +
|
||||
'kontrollera under Bokföring → Periodiseringar.',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('failed to cancel accrual schedules for credited invoice', err as Error)
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_CANCEL_PARTIAL',
|
||||
message:
|
||||
'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' +
|
||||
'Kontrollera under Bokföring → Periodiseringar.',
|
||||
})
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'credit_note.created',
|
||||
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
|
||||
})
|
||||
if (completeCreditNoteError || !completeCreditNote) {
|
||||
log.error('failed to read completed credit note', completeCreditNoteError)
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: completeCreditNote,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
})
|
||||
// A credit note is only issued when the user sends it or marks it as sent.
|
||||
// Until then it is a non-editable draft: no journal entry is created and
|
||||
// the original invoice remains in its current state.
|
||||
return NextResponse.json({ data: completeCreditNote })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,7 +40,7 @@ export const PUT = withRouteContext(
|
||||
// Fetch current settings to check for tax-relevant changes
|
||||
const { data: oldSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
@@ -48,6 +48,18 @@ export const PUT = withRouteContext(
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const reminderDays = [
|
||||
body.reminder_days_level_1 ?? oldSettings?.reminder_days_level_1 ?? 15,
|
||||
body.reminder_days_level_2 ?? oldSettings?.reminder_days_level_2 ?? 30,
|
||||
body.reminder_days_level_3 ?? oldSettings?.reminder_days_level_3 ?? 45,
|
||||
]
|
||||
if (!(reminderDays[0] < reminderDays[1] && reminderDays[1] < reminderDays[2])) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Påminnelsedagarna måste ligga i stigande ordning.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Lock org_number after onboarding is complete (legal identifier: changing it
|
||||
// would orphan vouchers, SIE history, and tax filings). company_name remains
|
||||
// editable so users can update their display/brand name (e.g. särskilt företagsnamn).
|
||||
@@ -121,6 +133,9 @@ export const PUT = withRouteContext(
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
|
||||
}))
|
||||
|
||||
const mockLinkToJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
@@ -128,6 +133,7 @@ describe('GET /api/supplier-invoices', () => {
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
|
||||
const DOCUMENT_UUID = '550e8400-e29b-41d4-a716-446655440002'
|
||||
|
||||
describe('POST /api/supplier-invoices', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
@@ -221,6 +227,77 @@ describe('POST /api/supplier-invoices', () => {
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores an uploaded document and links it to the registration entry', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-with-document', document_id: DOCUMENT_UUID })
|
||||
|
||||
enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 6 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-document' })
|
||||
enqueue({ data: null, error: null })
|
||||
mockLinkToJournalEntry.mockResolvedValue({ id: DOCUMENT_UUID })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
document_id: DOCUMENT_UUID,
|
||||
supplier_invoice_number: 'LF-DOCUMENT',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items: [
|
||||
{ description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { document_id: string; registration_journal_entry_id: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.document_id).toBe(DOCUMENT_UUID)
|
||||
expect(body.data.registration_journal_entry_id).toBe('je-document')
|
||||
expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
|
||||
mockSupabase,
|
||||
'company-1',
|
||||
DOCUMENT_UUID,
|
||||
'je-document',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a document that is missing or outside the active company', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
document_id: DOCUMENT_UUID,
|
||||
supplier_invoice_number: 'LF-INVALID-DOCUMENT',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items: [
|
||||
{ description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
expect(mockLinkToJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits supplier_invoice.registered event', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
@@ -12,6 +12,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -68,6 +69,42 @@ export const POST = withRouteContext(
|
||||
const body = validation.data
|
||||
const paidPrivately = body.paid_with_private_funds === true
|
||||
|
||||
if (body.document_id) {
|
||||
const { data: document, error: documentError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('id', body.document_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (documentError || !document || document.journal_entry_id) {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'document_id is missing, belongs to another company, or is already linked' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: existingDocumentUse, error: existingDocumentUseError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_id', body.document_id)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (existingDocumentUseError) {
|
||||
log.error('supplier invoice document usage lookup failed', existingDocumentUseError)
|
||||
return errorResponse(existingDocumentUseError, log, { requestId })
|
||||
}
|
||||
|
||||
if (existingDocumentUse) {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'document_id is already used by a supplier invoice' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (paidPrivately && body.reverse_charge) {
|
||||
// RC invoices come from registered businesses with formal invoices and
|
||||
// go through normal AP. "Privately paid" only makes sense for
|
||||
@@ -236,6 +273,7 @@ export const POST = withRouteContext(
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
supplier_id: body.supplier_id,
|
||||
document_id: body.document_id || null,
|
||||
arrival_number: arrivalNum,
|
||||
supplier_invoice_number: body.supplier_invoice_number,
|
||||
invoice_date: body.invoice_date,
|
||||
@@ -502,6 +540,28 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const primaryJournalEntryId = paymentJournalEntryId || registrationJournalEntryId
|
||||
if (body.document_id && primaryJournalEntryId) {
|
||||
try {
|
||||
await linkToJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
body.document_id,
|
||||
primaryJournalEntryId,
|
||||
)
|
||||
} catch (err) {
|
||||
log.warn('supplier invoice document could not be linked to journal entry', {
|
||||
documentId: body.document_id,
|
||||
journalEntryId: primaryJournalEntryId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
warnings.push({
|
||||
code: 'DOCUMENT_LINK_FAILED',
|
||||
message: 'Fakturan registrerades, men underlaget kunde inte kopplas till verifikationen.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.registered',
|
||||
|
||||
@@ -309,6 +309,43 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => {
|
||||
expect(body.error.code).toBe('LINK_TX_INVOICE_NOT_OPEN')
|
||||
})
|
||||
|
||||
it('returns 400 before linking when supplied invoice is a credit note', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000 }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 1,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: makeInvoice({
|
||||
id: INV_UUID,
|
||||
status: 'sent',
|
||||
total: -1000,
|
||||
remaining_amount: -1000,
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('LINK_TX_INVOICE_CREDIT_NOTE')
|
||||
})
|
||||
|
||||
it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses and rolls back the tx link', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }),
|
||||
|
||||
@@ -196,6 +196,51 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_INVOICE_TYPE')
|
||||
})
|
||||
|
||||
it('rejects matching an original invoice with an active credit-note draft', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const invoice = {
|
||||
...makeInvoice({ id: VALID_UUID, status: 'sent', credited_invoice_id: null }),
|
||||
credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }],
|
||||
}
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 before booking when matching against a credit note', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const creditNote = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: -12500,
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: creditNote, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is not in unpaid state', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' })
|
||||
|
||||
@@ -82,7 +82,7 @@ export const POST = withRouteContext(
|
||||
|
||||
const { data: invoice, error: fetchInvError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
|
||||
.eq('id', invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -104,6 +104,23 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
if (invoice.credited_invoice_id) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, { requestId })
|
||||
}
|
||||
|
||||
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> }).credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (activeCreditNotes.length > 0) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, {
|
||||
requestId,
|
||||
details: { reason: 'active_credit_note' },
|
||||
})
|
||||
}
|
||||
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
|
||||
return errorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, {
|
||||
requestId,
|
||||
|
||||
@@ -571,6 +571,47 @@ describe('POST :id/match-invoice', () => {
|
||||
expect((await res.json()).error.code).toBe('MATCH_INVOICE_TX_ALREADY_LINKED')
|
||||
})
|
||||
|
||||
it('rejects a credit note before creating a payment journal entry', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: {
|
||||
data: {
|
||||
id: TX_ID,
|
||||
amount: 12500,
|
||||
date: '2026-05-12',
|
||||
currency: 'SEK',
|
||||
invoice_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
invoices: {
|
||||
data: {
|
||||
id: INV_ID,
|
||||
status: 'sent',
|
||||
document_type: 'invoice',
|
||||
total: -12500,
|
||||
credited_invoice_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await matchInvoicePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
|
||||
{ invoice_id: INV_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
|
||||
expect(createInvPmtJE).not.toHaveBeenCalled()
|
||||
expect(createInvCashJE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The v1 route threads resolveSettlementAccount(transaction.cash_account_id)
|
||||
// exactly like the dashboard route and the agent/MCP commit path; these
|
||||
// regression tests were missing here (flagged in triage on #987) even
|
||||
|
||||
@@ -180,6 +180,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
details: { documentType: docType },
|
||||
})
|
||||
}
|
||||
if (invoice.credited_invoice_id) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
if (
|
||||
invoice.status !== 'sent' &&
|
||||
invoice.status !== 'overdue' &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Upload, FileText, ImageIcon, X, Loader2 } from 'lucide-react'
|
||||
@@ -45,10 +46,13 @@ function isImageType(type: string): boolean {
|
||||
* returned by /api/documents. Falls back to message_en or null if the
|
||||
* shape is unexpected.
|
||||
*/
|
||||
function extractErrorMessage(err: unknown): string | null {
|
||||
function extractErrorMessage(err: unknown, locale: string): string | null {
|
||||
if (typeof err === 'string') return err
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as { message?: unknown; message_en?: unknown; code?: unknown }
|
||||
if (locale === 'en' && typeof e.message_en === 'string' && e.message_en.length > 0) {
|
||||
return e.message_en
|
||||
}
|
||||
if (typeof e.message === 'string' && e.message.length > 0) return e.message
|
||||
if (typeof e.message_en === 'string' && e.message_en.length > 0) return e.message_en
|
||||
if (typeof e.code === 'string') return e.code
|
||||
@@ -64,6 +68,8 @@ export default function DocumentUploadZone({
|
||||
disabled = false,
|
||||
compact = false,
|
||||
}: DocumentUploadZoneProps) {
|
||||
const t = useTranslations('document_upload')
|
||||
const locale = useLocale()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -91,13 +97,13 @@ export default function DocumentUploadZone({
|
||||
fileName: file.fileName,
|
||||
})
|
||||
const reason = res.status === 401 || res.status === 403
|
||||
? 'Din session har gått ut. Ladda om sidan och logga in igen.'
|
||||
: `Servern svarade ${res.status}.`
|
||||
? t('session_expired')
|
||||
: t('server_status', { status: res.status })
|
||||
return { ...file, status: 'error', error: reason }
|
||||
}
|
||||
|
||||
if (!res.ok || result.error) {
|
||||
const errMessage = extractErrorMessage(result.error) || `Uppladdning misslyckades (${res.status})`
|
||||
const errMessage = extractErrorMessage(result.error, locale) || t('failed_status', { status: res.status })
|
||||
console.warn('[DocumentUploadZone] Upload error', {
|
||||
status: res.status,
|
||||
error: result.error,
|
||||
@@ -112,9 +118,9 @@ export default function DocumentUploadZone({
|
||||
error: err,
|
||||
fileName: file.fileName,
|
||||
})
|
||||
return { ...file, status: 'error', error: 'Uppladdning misslyckades: nätverksfel' }
|
||||
return { ...file, status: 'error', error: t('network_error') }
|
||||
}
|
||||
}, [journalEntryId])
|
||||
}, [journalEntryId, locale, t])
|
||||
|
||||
const handleFiles = useCallback(async (newFiles: File[]) => {
|
||||
const remaining = maxFiles - files.length
|
||||
@@ -127,7 +133,7 @@ export default function DocumentUploadZone({
|
||||
validFiles.push({
|
||||
file,
|
||||
status: 'error',
|
||||
error: 'Filtypen stöds inte',
|
||||
error: t('unsupported_type'),
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
uploadKey: `upload-${++uploadCounter}`,
|
||||
@@ -138,7 +144,7 @@ export default function DocumentUploadZone({
|
||||
validFiles.push({
|
||||
file,
|
||||
status: 'error',
|
||||
error: 'Filen är för stor (max 10 MB)',
|
||||
error: t('too_large'),
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
uploadKey: `upload-${++uploadCounter}`,
|
||||
@@ -165,7 +171,7 @@ export default function DocumentUploadZone({
|
||||
)
|
||||
onFilesChange([...currentFiles])
|
||||
}
|
||||
}, [files, maxFiles, onFilesChange, uploadFile])
|
||||
}, [files, maxFiles, onFilesChange, t, uploadFile])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -230,11 +236,11 @@ export default function DocumentUploadZone({
|
||||
<Upload className={compact ? 'h-4 w-4 text-muted-foreground' : 'mx-auto h-8 w-8 text-muted-foreground'} />
|
||||
<div>
|
||||
<p className={compact ? 'text-sm text-muted-foreground' : 'text-sm font-medium'}>
|
||||
{compact ? 'Dra och släpp eller klicka' : 'Dra och släpp filer här'}
|
||||
{compact ? t('compact_prompt') : t('prompt')}
|
||||
</p>
|
||||
{!compact && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PDF, bilder (max 10 MB)
|
||||
{t('format_hint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -264,13 +270,13 @@ export default function DocumentUploadZone({
|
||||
)}
|
||||
{file.status === 'uploaded' && (
|
||||
<Badge variant="success" className="text-xs px-1.5 py-0">
|
||||
Uppladdad
|
||||
{t('uploaded')}
|
||||
</Badge>
|
||||
)}
|
||||
{file.status === 'error' && (
|
||||
<>
|
||||
<Badge variant="destructive" className="text-xs px-1.5 py-0">
|
||||
Fel
|
||||
{t('error')}
|
||||
</Badge>
|
||||
{file.error && (
|
||||
<span className="text-xs text-destructive">{file.error}</span>
|
||||
@@ -281,7 +287,7 @@ export default function DocumentUploadZone({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Ta bort fil"
|
||||
aria-label={t('remove_file')}
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -297,7 +303,7 @@ export default function DocumentUploadZone({
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<p className="text-xs text-muted-foreground">Laddar upp...</p>
|
||||
<p className="text-xs text-muted-foreground">{t('uploading')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -38,6 +39,7 @@ interface DocumentRecord {
|
||||
storage_path: string
|
||||
created_at: string
|
||||
download_url?: string
|
||||
referenced?: boolean
|
||||
}
|
||||
|
||||
interface JournalEntryAttachmentsProps {
|
||||
@@ -90,12 +92,45 @@ export default function JournalEntryAttachments({
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/documents?journal_entry_id=${journalEntryId}¤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<string>(
|
||||
(referenceData?.references || [])
|
||||
.map((reference: { document_id?: string }) => reference.document_id)
|
||||
.filter((documentId: string | undefined): documentId is string => (
|
||||
Boolean(documentId) && !directIds.has(documentId as string)
|
||||
)),
|
||||
))
|
||||
|
||||
const referencedDocuments = await Promise.all(
|
||||
documentIds.map(async (documentId) => {
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${documentId}`)
|
||||
if (!response.ok) return null
|
||||
const { data } = await response.json()
|
||||
return data ? { ...data, referenced: true } as DocumentRecord : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
referenced = referencedDocuments.filter(
|
||||
(document): document is DocumentRecord => document !== null,
|
||||
)
|
||||
}
|
||||
|
||||
const allDocuments = [...direct, ...referenced]
|
||||
setDocuments(allDocuments)
|
||||
onCountChangeRef.current?.(allDocuments.length)
|
||||
} catch {
|
||||
// Non-critical: silently ignore
|
||||
} finally {
|
||||
@@ -286,36 +321,45 @@ export default function JournalEntryAttachments({
|
||||
)}
|
||||
|
||||
<span className="truncate flex-1">{doc.file_name}</span>
|
||||
{doc.referenced && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{t('via_supplier_invoice')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(doc.file_size_bytes)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
|
||||
onClick={() => handleOpenReplacePicker(doc.id)}
|
||||
disabled={isReplacing}
|
||||
title={t('replace')}
|
||||
aria-label={t('replace')}
|
||||
>
|
||||
{isReplacing ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
{!doc.referenced && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
|
||||
onClick={() => handleOpenReplacePicker(doc.id)}
|
||||
disabled={isReplacing}
|
||||
title={t('replace')}
|
||||
aria-label={t('replace')}
|
||||
>
|
||||
{isReplacing ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
|
||||
onClick={() => handleRequestRemove(doc)}
|
||||
title={t('remove')}
|
||||
aria-label={t('remove')}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
|
||||
onClick={() => handleRequestRemove(doc)}
|
||||
title={t('remove')}
|
||||
aria-label={t('remove')}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function CustomerForm({
|
||||
vat_number: z.string().optional(),
|
||||
personal_number: z
|
||||
.string()
|
||||
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, t('personal_number_invalid'))
|
||||
.regex(/^(?:(\d{6}|\d{8})[-+]?\d{4}|\*{8}-\d{4})$/, t('personal_number_invalid'))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
language: z.enum(['sv', 'en']).optional(),
|
||||
@@ -133,11 +133,15 @@ export default function CustomerForm({
|
||||
}
|
||||
|
||||
const onFormSubmit = (data: FormData) => {
|
||||
onSubmit({
|
||||
const payload: CreateCustomerInput = {
|
||||
...data,
|
||||
email: data.email || undefined,
|
||||
personal_number: data.personal_number || undefined,
|
||||
})
|
||||
personal_number: data.personal_number || null,
|
||||
}
|
||||
if (data.personal_number?.startsWith('*') && data.personal_number === initialData?.personal_number) {
|
||||
delete payload.personal_number
|
||||
}
|
||||
onSubmit(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,7 +25,7 @@ import { formatCurrency } from '@/lib/utils'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags } from 'lucide-react'
|
||||
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Copy } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -60,6 +60,7 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||||
import type { InvoiceCopyInitial } from '@/lib/invoices/copy-invoice'
|
||||
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } from '@/types'
|
||||
|
||||
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
@@ -77,6 +78,7 @@ export type InvoiceForEdit = Invoice & { items: InvoiceItem[] }
|
||||
export type InvoiceEditorProps = (
|
||||
| { mode?: 'create' }
|
||||
| { mode: 'edit'; initial: InvoiceForEdit }
|
||||
| { mode: 'copy'; initial: InvoiceCopyInitial }
|
||||
) & { bare?: boolean }
|
||||
|
||||
// Subset of Article fields the line picker needs to pre-fill a row.
|
||||
@@ -106,7 +108,10 @@ function compactDims(dims: Record<string, string>): string {
|
||||
export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'create' }) {
|
||||
// Edit mode pre-fills the form from an existing draft and saves via PATCH.
|
||||
const isEditMode = props.mode === 'edit'
|
||||
const isCopyMode = props.mode === 'copy'
|
||||
const initial = props.mode === 'edit' ? props.initial : null
|
||||
const copyInitial = props.mode === 'copy' ? props.initial : null
|
||||
const initialOreRounding = initial?.ore_rounding ?? copyInitial?.ore_rounding
|
||||
const bare = props.bare === true
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -271,7 +276,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
// Öresavrundning is display-only. In edit mode the draft's stored flag wins;
|
||||
// otherwise it defaults to the company-wide setting (loaded below).
|
||||
const [oreRounding, setOreRounding] = useState<boolean>(
|
||||
typeof initial?.ore_rounding === 'boolean' ? initial.ore_rounding : true,
|
||||
typeof initialOreRounding === 'boolean' ? initialOreRounding : true,
|
||||
)
|
||||
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
|
||||
const [numberPreview, setNumberPreview] = useState<string | null>(null)
|
||||
@@ -291,7 +296,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
// open/close bookkeeping as accountOverrideRows).
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const [defaultDims, setDefaultDims] = useState<Record<string, string>>(
|
||||
initial?.default_dimensions ?? {},
|
||||
initial?.default_dimensions ?? copyInitial?.default_dimensions ?? {},
|
||||
)
|
||||
const [dimensionOverrideRows, setDimensionOverrideRows] = useState<Set<number>>(new Set())
|
||||
// True only when the user had zero invoices when this page loaded. The
|
||||
@@ -362,6 +367,26 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
dimensions: hasDimensionValues(item.dimensions) ? item.dimensions ?? null : null,
|
||||
})),
|
||||
}
|
||||
: copyInitial
|
||||
? {
|
||||
customer_id: copyInitial.customer_id,
|
||||
invoice_date: '',
|
||||
due_date: '',
|
||||
delivery_date: '',
|
||||
currency: copyInitial.currency,
|
||||
document_type: 'invoice' as InvoiceDocumentType,
|
||||
your_reference: '',
|
||||
our_reference: copyInitial.our_reference,
|
||||
notes: copyInitial.notes,
|
||||
payment_link_url: '',
|
||||
payment_link_auto: true,
|
||||
external_invoice_number: '',
|
||||
self_billing_agreement_ref: '',
|
||||
received_date: '',
|
||||
deduction_personnummer: '',
|
||||
deduction_housing_designation: '',
|
||||
items: copyInitial.items,
|
||||
}
|
||||
: {
|
||||
customer_id: '',
|
||||
invoice_date: '',
|
||||
@@ -542,11 +567,13 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
.single()
|
||||
if (data?.invoice_default_notes) {
|
||||
setDefaultNotes(data.invoice_default_notes)
|
||||
setValue('notes', data.invoice_default_notes)
|
||||
if (!isEditMode && !isCopyMode) {
|
||||
setValue('notes', data.invoice_default_notes)
|
||||
}
|
||||
}
|
||||
// Pre-fill "Vår referens" from the company default: only when creating a
|
||||
// fresh invoice, so an edited draft's own reference is never overwritten.
|
||||
if (!isEditMode && data?.default_our_reference) {
|
||||
if (!isEditMode && !isCopyMode && data?.default_our_reference) {
|
||||
setValue('our_reference', data.default_our_reference)
|
||||
}
|
||||
setHasBankDetails(
|
||||
@@ -557,7 +584,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
}
|
||||
// An explicit per-invoice flag (edit mode) wins; only fall back to the
|
||||
// company-wide setting when creating or when the draft never set one.
|
||||
if (typeof data?.ore_rounding === 'boolean' && (!isEditMode || initial?.ore_rounding == null)) {
|
||||
if (typeof data?.ore_rounding === 'boolean' && initialOreRounding == null) {
|
||||
setOreRounding(data.ore_rounding)
|
||||
}
|
||||
setLogoUrl(data?.logo_url ?? null)
|
||||
@@ -1266,6 +1293,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
|
||||
const titleText = isEditMode
|
||||
? t('title_edit')
|
||||
: isCopyMode
|
||||
? t('title_copy')
|
||||
: isSelfBilled
|
||||
? ts('title')
|
||||
: watchDocumentType === 'proforma'
|
||||
@@ -1275,6 +1304,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
: t('title_invoice')
|
||||
const subtitleText = isEditMode
|
||||
? t('subtitle_edit')
|
||||
: isCopyMode
|
||||
? t('subtitle_copy')
|
||||
: isSelfBilled
|
||||
? ts('subtitle')
|
||||
: watchDocumentType === 'proforma'
|
||||
@@ -1314,7 +1345,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditMode && (
|
||||
{isCopyMode && copyInitial && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-sm">
|
||||
<Copy className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
{t('copy_notice', { number: copyInitial.source_invoice_number })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditMode && !isCopyMode && (
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'invoice' | 'self_billed')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="invoice">{t('mode_invoice')}</TabsTrigger>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
buildInvoiceCopyInitial,
|
||||
canCopyInvoice,
|
||||
type InvoiceCopyInitial,
|
||||
type InvoiceCopySource,
|
||||
} from '@/lib/invoices/copy-invoice'
|
||||
|
||||
// Deferred: the editor (and its framer-motion dependency) is a large chunk
|
||||
// that would otherwise ship with the invoice LIST bundle — it's only needed
|
||||
// that would otherwise ship with the invoice LIST bundle: it is only needed
|
||||
// once this dialog actually opens.
|
||||
const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor'), {
|
||||
ssr: false,
|
||||
@@ -22,6 +33,7 @@ const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor'
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
copyFromId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,8 +47,70 @@ interface Props {
|
||||
* shows the invoice-number preview: a static DialogTitle would duplicate or
|
||||
* contradict it.
|
||||
*/
|
||||
export default function NewInvoiceDialog({ open, onOpenChange }: Props) {
|
||||
export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null }: Props) {
|
||||
const t = useTranslations('invoice_editor')
|
||||
const { company } = useCompany()
|
||||
const supabase = useMemo(() => createClient(), [])
|
||||
const [copyLoad, setCopyLoad] = useState<{
|
||||
sourceId: string | null
|
||||
initial: InvoiceCopyInitial | null
|
||||
failed: boolean
|
||||
}>({ sourceId: null, initial: null, failed: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !copyFromId) return
|
||||
if (!company?.id) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const loadCopySource = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('*')
|
||||
.eq('id', copyFromId)
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
|
||||
let items: Record<string, unknown>[] = []
|
||||
if (!error && data) {
|
||||
try {
|
||||
items = await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoice_items')
|
||||
.select('*')
|
||||
.eq('invoice_id', copyFromId)
|
||||
.eq('company_id', company.id)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
} catch {
|
||||
if (!cancelled) setCopyLoad({ sourceId: copyFromId, initial: null, failed: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
const source = data ? { ...data, items } : null
|
||||
if (error || !source || !canCopyInvoice(source)) {
|
||||
setCopyLoad({ sourceId: copyFromId, initial: null, failed: true })
|
||||
return
|
||||
}
|
||||
setCopyLoad({
|
||||
sourceId: copyFromId,
|
||||
initial: buildInvoiceCopyInitial(source as InvoiceCopySource),
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
void loadCopySource()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company?.id, copyFromId, open, supabase])
|
||||
|
||||
const copyInitial = copyLoad.sourceId === copyFromId ? copyLoad.initial : null
|
||||
const copyLoadFailed = copyLoad.sourceId === copyFromId && copyLoad.failed
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -50,8 +124,30 @@ export default function NewInvoiceDialog({ open, onOpenChange }: Props) {
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">{t('title_invoice')}</DialogTitle>
|
||||
<InvoiceEditor mode="create" bare />
|
||||
<DialogTitle className="sr-only">
|
||||
{copyFromId ? t('title_copy') : t('title_invoice')}
|
||||
</DialogTitle>
|
||||
{copyFromId ? (
|
||||
copyLoadFailed ? (
|
||||
<div className="space-y-4 p-6 text-center">
|
||||
<p className="font-medium">{t('copy_load_failed_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('copy_load_failed_description')}</p>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
</div>
|
||||
) : copyInitial ? (
|
||||
<InvoiceEditor key={copyFromId} mode="copy" initial={copyInitial} bare />
|
||||
) : (
|
||||
<div className="space-y-4 p-6">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<InvoiceEditor mode="create" bare />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,8 +16,10 @@ import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntry
|
||||
import { proposeSendLines } from '@/lib/bookkeeping/propose-send-lines'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { getResponseErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
|
||||
import { Loader2, Mail, Send } from 'lucide-react'
|
||||
import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types'
|
||||
|
||||
@@ -47,12 +49,16 @@ export default function SendInvoiceDialog({
|
||||
const { company, isSandbox } = useCompany()
|
||||
const canEmail = useCapability(CAPABILITY.email_send)
|
||||
const t = useTranslations('invoice_send_dialog')
|
||||
const locale = useLocale() as 'sv' | 'en'
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const isCreditRepair = isCreditNote && invoice.status === 'sent'
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
|
||||
const [periodName, setPeriodName] = useState('')
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -66,30 +72,44 @@ export default function SendInvoiceDialog({
|
||||
try {
|
||||
if (!company?.id) throw new Error(t('no_active_company'))
|
||||
|
||||
// Fetch company settings
|
||||
const { data: settings, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
const [settingsResult, periodResult, originalResult] = await Promise.all([
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('name')
|
||||
.eq('company_id', company.id)
|
||||
.lte('start_date', invoice.invoice_date)
|
||||
.gte('end_date', invoice.invoice_date)
|
||||
.maybeSingle(),
|
||||
invoice.credited_invoice_id
|
||||
? supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
: Promise.resolve({ data: null, error: null }),
|
||||
])
|
||||
|
||||
if (error) throw new Error(t('company_settings_failed'))
|
||||
if (cancelled) return
|
||||
|
||||
// Fetch fiscal period for the invoice date
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('name')
|
||||
.eq('company_id', company.id)
|
||||
.lte('start_date', invoice.invoice_date)
|
||||
.gte('end_date', invoice.invoice_date)
|
||||
.maybeSingle()
|
||||
if (settingsResult.error) throw new Error(t('company_settings_failed'))
|
||||
if (periodResult.error) throw new Error(t('fiscal_period_failed'))
|
||||
if (originalResult.error) throw new Error(t('original_invoice_failed'))
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
setAccountingMethod((settings?.accounting_method || 'accrual') as 'accrual' | 'cash')
|
||||
setEntityType((settings?.entity_type as EntityType) || 'enskild_firma')
|
||||
setPeriodName(period?.name || '')
|
||||
const method = (settingsResult.data?.accounting_method || 'accrual') as 'accrual' | 'cash'
|
||||
setAccountingMethod(method)
|
||||
setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma')
|
||||
setPeriodName(periodResult.data?.name || '')
|
||||
setShouldBookOnIssue(
|
||||
invoice.credited_invoice_id && originalResult.data
|
||||
? creditNoteNeedsJournalEntry(method, originalResult.data)
|
||||
: method === 'accrual',
|
||||
)
|
||||
setIsInitialized(true)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
@@ -107,7 +127,7 @@ export default function SendInvoiceDialog({
|
||||
}, [open, invoice.id, invoice.invoice_date, company?.id])
|
||||
|
||||
const proposedLines = useMemo(() => {
|
||||
if (!isInitialized || accountingMethod !== 'accrual') return []
|
||||
if (!isInitialized || !shouldBookOnIssue) return []
|
||||
|
||||
return proposeSendLines({
|
||||
invoice: {
|
||||
@@ -121,12 +141,13 @@ export default function SendInvoiceDialog({
|
||||
currency: invoice.currency,
|
||||
exchange_rate: invoice.exchange_rate,
|
||||
vat_treatment: invoice.vat_treatment,
|
||||
credited_invoice_id: invoice.credited_invoice_id,
|
||||
items: invoice.items,
|
||||
default_dimensions: invoice.default_dimensions,
|
||||
},
|
||||
entityType,
|
||||
})
|
||||
}, [isInitialized, accountingMethod, entityType, invoice])
|
||||
}, [isInitialized, shouldBookOnIssue, entityType, invoice])
|
||||
|
||||
const { totalDebit, totalCredit } = useMemo(() => {
|
||||
let totalDebit = 0
|
||||
@@ -147,33 +168,62 @@ export default function SendInvoiceDialog({
|
||||
: `/api/invoices/${invoice.id}/mark-sent`
|
||||
|
||||
const response = await fetch(url, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t('send_failed_fallback'))
|
||||
throw new Error(await getResponseErrorMessage(response, 'invoice', locale))
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
onSuccess()
|
||||
|
||||
if (mode === 'email') {
|
||||
onOpenChange(false)
|
||||
const successMessage = data.message || t('send_success_default', { email: invoice.customer.email ?? '' })
|
||||
toast({
|
||||
title: t('send_success_title'),
|
||||
description: data.message || t('send_success_default', { email: invoice.customer.email ?? '' }),
|
||||
title: t(
|
||||
shouldBookOnIssue && !data.partial
|
||||
? isCreditNote
|
||||
? 'credit_send_book_success_title'
|
||||
: 'send_book_success_title'
|
||||
: isCreditNote
|
||||
? 'credit_send_success_title'
|
||||
: 'send_success_title',
|
||||
),
|
||||
description: data.partial
|
||||
? t('partial_success', { message: successMessage })
|
||||
: isCreditNote
|
||||
? t('credit_send_success', { email: invoice.customer.email ?? '' })
|
||||
: successMessage,
|
||||
})
|
||||
} else {
|
||||
// For manual send, just close: no email to confirm
|
||||
onOpenChange(false)
|
||||
toast({
|
||||
title: t('mark_success_title'),
|
||||
description: accountingMethod === 'accrual'
|
||||
? t('mark_success_voucher_created')
|
||||
: undefined,
|
||||
title: t(
|
||||
isCreditRepair
|
||||
? 'credit_repair_success_title'
|
||||
: shouldBookOnIssue && !data.partial
|
||||
? isCreditNote
|
||||
? 'credit_mark_book_success_title'
|
||||
: 'mark_book_success_title'
|
||||
: isCreditNote
|
||||
? 'credit_mark_success_title'
|
||||
: 'mark_success_title',
|
||||
),
|
||||
description: data.partial
|
||||
? t('mark_partial_success')
|
||||
: isCreditNote
|
||||
? shouldBookOnIssue
|
||||
? t('credit_mark_success_voucher_created')
|
||||
: t('credit_mark_success_no_voucher')
|
||||
: accountingMethod === 'accrual'
|
||||
? t('mark_success_voucher_created')
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('send_failed_title'),
|
||||
title: t(isCreditNote ? 'credit_send_failed_title' : 'send_failed_title'),
|
||||
description: error instanceof Error ? error.message : t('try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -186,14 +236,25 @@ export default function SendInvoiceDialog({
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const showJournalPreview = accountingMethod === 'accrual' && proposedLines.length > 0
|
||||
const showJournalPreview = shouldBookOnIssue && proposedLines.length > 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'email' ? t('title_email') : t('title_manual')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
|
||||
{t(
|
||||
isCreditRepair
|
||||
? 'title_credit_repair'
|
||||
: isCreditNote
|
||||
? mode === 'email'
|
||||
? 'title_credit_email'
|
||||
: 'title_credit_manual'
|
||||
: mode === 'email'
|
||||
? 'title_email'
|
||||
: 'title_manual',
|
||||
)}
|
||||
{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
@@ -236,7 +297,7 @@ export default function SendInvoiceDialog({
|
||||
<JournalEntryReviewContent
|
||||
periodName={periodName}
|
||||
entryDate={invoice.invoice_date}
|
||||
description={t('voucher_description', {
|
||||
description={t(isCreditNote ? 'credit_voucher_description' : 'voucher_description', {
|
||||
numberSpace: invoice.invoice_number ? ` ${invoice.invoice_number}` : '',
|
||||
customerSuffix: invoice.customer.name ? `, ${invoice.customer.name}` : '',
|
||||
})}
|
||||
@@ -249,8 +310,8 @@ export default function SendInvoiceDialog({
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{accountingMethod === 'cash'
|
||||
? t('explain_cash')
|
||||
{!shouldBookOnIssue
|
||||
? t(isCreditNote ? 'explain_credit_cash' : 'explain_cash')
|
||||
: mode === 'email'
|
||||
? t('explain_email', { email: invoice.customer.email ?? '' })
|
||||
: t('explain_manual')}
|
||||
@@ -266,7 +327,7 @@ export default function SendInvoiceDialog({
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
>
|
||||
{t('cancel')}
|
||||
{t(isCreditNote ? 'later' : 'cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
@@ -287,7 +348,25 @@ export default function SendInvoiceDialog({
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{mode === 'email' ? t('send_invoice') : t('mark_as_sent')}
|
||||
{t(
|
||||
isCreditRepair
|
||||
? 'complete_credit_bookkeeping'
|
||||
: isCreditNote
|
||||
? mode === 'email'
|
||||
? shouldBookOnIssue
|
||||
? 'send_credit_note_and_book'
|
||||
: 'send_credit_note'
|
||||
: shouldBookOnIssue
|
||||
? 'mark_credit_note_sent_and_book'
|
||||
: 'mark_credit_note_sent'
|
||||
: mode === 'email'
|
||||
? shouldBookOnIssue
|
||||
? 'send_invoice_and_book'
|
||||
: 'send_invoice'
|
||||
: shouldBookOnIssue
|
||||
? 'mark_as_sent_and_book'
|
||||
: 'mark_as_sent',
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -106,7 +106,7 @@ const SCOPE_GROUPS: ScopeGroup[] = [
|
||||
{
|
||||
domain: 'companies',
|
||||
labelKey: 'group_companies',
|
||||
read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 0 },
|
||||
read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 1 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -92,6 +92,46 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
|
||||
{t('default_our_reference_help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-4 border-t border-border pt-4">
|
||||
<legend className="text-sm font-medium">{t('reminder_days_heading')}</legend>
|
||||
<p className="text-xs text-muted-foreground">{t('reminder_days_help')}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder_days_level_1">{t('reminder_days_level_1')}</Label>
|
||||
<Input
|
||||
id="reminder_days_level_1"
|
||||
name="reminder_days_level_1"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
defaultValue={settings.reminder_days_level_1 ?? 15}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder_days_level_2">{t('reminder_days_level_2')}</Label>
|
||||
<Input
|
||||
id="reminder_days_level_2"
|
||||
name="reminder_days_level_2"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
defaultValue={settings.reminder_days_level_2 ?? 30}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder_days_level_3">{t('reminder_days_level_3')}</Label>
|
||||
<Input
|
||||
id="reminder_days_level_3"
|
||||
name="reminder_days_level_3"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
defaultValue={settings.reminder_days_level_3 ?? 45}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ export function InvoicingSettingsContent() {
|
||||
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
|
||||
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
|
||||
default_our_reference: (formData.get('default_our_reference') as string) || null,
|
||||
reminder_days_level_1:
|
||||
Number.parseInt(formData.get('reminder_days_level_1') as string) || 15,
|
||||
reminder_days_level_2:
|
||||
Number.parseInt(formData.get('reminder_days_level_2') as string) || 30,
|
||||
reminder_days_level_3:
|
||||
Number.parseInt(formData.get('reminder_days_level_3') as string) || 45,
|
||||
}
|
||||
return {
|
||||
updates,
|
||||
|
||||
@@ -28,9 +28,10 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
|
||||
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
import DocumentUploadZone, { type UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||||
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock, Tags } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock, Tags, Paperclip } from 'lucide-react'
|
||||
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types'
|
||||
|
||||
interface LineItem {
|
||||
@@ -66,6 +67,7 @@ interface ExistingSupplierInvoice {
|
||||
// paths still return a flat string, so accept both.
|
||||
interface CreateResult {
|
||||
data?: { id: string; arrival_number: number }
|
||||
warnings?: Array<{ code: string; message: string }>
|
||||
error?:
|
||||
| string
|
||||
| {
|
||||
@@ -303,6 +305,7 @@ export default function NewSupplierInvoiceForm({
|
||||
// with onCreated (dialog mode), otherwise navigate like the old standalone
|
||||
// page did.
|
||||
const finishCreate = (invoiceId?: string) => {
|
||||
createFinishedRef.current = true
|
||||
if (onCreated) onCreated(invoiceId)
|
||||
else router.push(afterCreate(invoiceId))
|
||||
}
|
||||
@@ -331,6 +334,9 @@ export default function NewSupplierInvoiceForm({
|
||||
const [pendingSupplierSelect, setPendingSupplierSelect] = useState<string | null>(null)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [newSupplier, setNewSupplier] = useState<NewSupplierForm>(EMPTY_NEW_SUPPLIER)
|
||||
const [documentFiles, setDocumentFiles] = useState<UploadedFile[]>([])
|
||||
const documentFilesRef = useRef<UploadedFile[]>([])
|
||||
const createFinishedRef = useRef(false)
|
||||
|
||||
// Inbox/AI state
|
||||
const [extractedData, setExtractedData] = useState<InvoiceExtractionResult | null>(null)
|
||||
@@ -379,6 +385,19 @@ export default function NewSupplierInvoiceForm({
|
||||
|
||||
useUnsavedChanges(isDirty)
|
||||
|
||||
useEffect(() => {
|
||||
documentFilesRef.current = documentFiles
|
||||
}, [documentFiles])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (inboxItemId || createFinishedRef.current) return
|
||||
for (const file of documentFilesRef.current) {
|
||||
if (file.status === 'uploaded' && file.id) {
|
||||
void fetch(`/api/documents/${file.id}`, { method: 'DELETE', keepalive: true })
|
||||
}
|
||||
}
|
||||
}, [inboxItemId])
|
||||
|
||||
const { fields, append, remove, replace } = useFieldArray({ control, name: 'items' })
|
||||
const watchedItems = watch('items')
|
||||
const watchedSupplierId = watch('supplier_id')
|
||||
@@ -392,6 +411,9 @@ export default function NewSupplierInvoiceForm({
|
||||
const watchedInvoiceDate = watch('invoice_date')
|
||||
const watchedDueDate = watch('due_date')
|
||||
const watchedPaymentReference = watch('payment_reference')
|
||||
const documentUploadInProgress = documentFiles.some((file) => file.status === 'uploading')
|
||||
const documentUploadFailed = documentFiles.some((file) => file.status === 'error')
|
||||
const uploadedDocumentId = documentFiles.find((file) => file.status === 'uploaded')?.id
|
||||
// Returns true when the field currently matches whatever the AI wrote
|
||||
// when the form first loaded. Edits diverge it, hiding the dot.
|
||||
function stillFromAi(value: string | null | undefined, original: string | null | undefined): boolean {
|
||||
@@ -943,6 +965,7 @@ export default function NewSupplierInvoiceForm({
|
||||
: data.due_date
|
||||
return {
|
||||
supplier_id: data.supplier_id,
|
||||
...(!inboxItemId && uploadedDocumentId ? { document_id: uploadedDocumentId } : {}),
|
||||
supplier_invoice_number: data.supplier_invoice_number,
|
||||
invoice_date: data.invoice_date,
|
||||
due_date: dueDate,
|
||||
@@ -1044,10 +1067,36 @@ export default function NewSupplierInvoiceForm({
|
||||
body: JSON.stringify(buildPayload(data)),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (
|
||||
res.ok &&
|
||||
(result as CreateResult).warnings?.some((warning) => warning.code === 'DOCUMENT_LINK_FAILED')
|
||||
) {
|
||||
toast({
|
||||
title: t('document_link_warning_title'),
|
||||
description: t('document_link_warning_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
return { ok: res.ok, status: res.status, result }
|
||||
}
|
||||
|
||||
function onSubmit(data: FormData) {
|
||||
if (documentUploadInProgress) {
|
||||
toast({
|
||||
title: t('document_upload_in_progress_title'),
|
||||
description: t('document_upload_in_progress_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (documentUploadFailed) {
|
||||
toast({
|
||||
title: t('document_upload_failed_title'),
|
||||
description: t('document_upload_failed_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Hard block: under faktureringsmetoden (and for privately-paid kvitton) a
|
||||
// verifikation is posted at registration, and BFL 5 kap kräver att
|
||||
// verifikationsnumret ligger i en obruten serie inom ett räkenskapsår. No
|
||||
@@ -1328,6 +1377,34 @@ export default function NewSupplierInvoiceForm({
|
||||
setTimeout(() => invoiceNumberInputRef.current?.focus(), 0)
|
||||
}
|
||||
|
||||
function handleDocumentFilesChange(nextFiles: UploadedFile[]) {
|
||||
const retainedKeys = new Set(nextFiles.map((file) => file.uploadKey))
|
||||
const removedDocuments = documentFiles.filter(
|
||||
(file) => file.id && !retainedKeys.has(file.uploadKey),
|
||||
)
|
||||
|
||||
setDocumentFiles(nextFiles)
|
||||
for (const document of removedDocuments) {
|
||||
void fetch(`/api/documents/${document.id}`, { method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
|
||||
async function discardUploadedDocument() {
|
||||
const ids = documentFiles
|
||||
.filter((file) => file.status === 'uploaded' && file.id)
|
||||
.map((file) => file.id as string)
|
||||
await Promise.allSettled(
|
||||
ids.map((documentId) => fetch(`/api/documents/${documentId}`, { method: 'DELETE' })),
|
||||
)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
void discardUploadedDocument().finally(() => {
|
||||
if (onCancel) onCancel()
|
||||
else router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices')
|
||||
})
|
||||
}
|
||||
|
||||
// Match-on-create: register the invoice, then match the picked transaction.
|
||||
// EF goes straight through (auto-approve included). AB stores the picked
|
||||
// transaction and routes through the same review dialog as the plain
|
||||
@@ -1559,6 +1636,25 @@ export default function NewSupplierInvoiceForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!inboxItemId && (
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Paperclip className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<Label>{t('document_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('document_help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentUploadZone
|
||||
files={documentFiles}
|
||||
onFilesChange={handleDocumentFilesChange}
|
||||
maxFiles={1}
|
||||
disabled={isSubmitting}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice-level default dims (kostnadsställe/projekt): applied to
|
||||
every generated journal line; per-row bags in Kontering merge on
|
||||
top. Renders only when dimensions are enabled for the company. */}
|
||||
@@ -2052,10 +2148,8 @@ export default function NewSupplierInvoiceForm({
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
if (onCancel) onCancel()
|
||||
else router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices')
|
||||
}}
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting || documentUploadInProgress}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
@@ -2064,7 +2158,7 @@ export default function NewSupplierInvoiceForm({
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={isSubmitting || !canWrite || showNoPeriodWarning}
|
||||
disabled={isSubmitting || documentUploadInProgress || !canWrite || showNoPeriodWarning}
|
||||
onClick={() => { submitModeRef.current = 'register_and_match' }}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
@@ -2074,7 +2168,7 @@ export default function NewSupplierInvoiceForm({
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !canWrite || showNoPeriodWarning}
|
||||
disabled={isSubmitting || documentUploadInProgress || !canWrite || showNoPeriodWarning}
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => { submitModeRef.current = 'register' }}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_type', 'invoice')
|
||||
.is('credited_invoice_id', null)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gt('remaining_amount', 0)
|
||||
.order('invoice_date', { ascending: false })
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function MatchAllocationDialog({
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_type', 'invoice')
|
||||
.is('credited_invoice_id', null)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gt('remaining_amount', 0)
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"dataPattern": "core",
|
||||
"readsCoreTables": ["invoices", "customers", "company_settings"],
|
||||
"description": "Skicka fakturor och påminnelser via e-post",
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
},
|
||||
},
|
||||
)
|
||||
const membershipChain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return () => membershipChain
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
@@ -48,7 +66,10 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Test Key',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: (table: string) => (table === 'company_members' ? membershipChain : chain),
|
||||
rpc: () => chain,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
addCompanyToNextHint,
|
||||
addCompanyToTopLevelNext,
|
||||
assertMcpCompanyWriteAccess,
|
||||
extractRequestedCompany,
|
||||
isCompanyDependentTool,
|
||||
projectToolInputSchema,
|
||||
resolveMcpCompanyContext,
|
||||
} from '../company-routing'
|
||||
|
||||
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function membershipClient(result: { data: unknown; error: unknown }) {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
is: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue(result),
|
||||
}
|
||||
return {
|
||||
client: { from: vi.fn(() => chain) },
|
||||
chain,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MCP company routing', () => {
|
||||
it('projects company_id onto company-dependent tool schemas without mutating the source', () => {
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { invoice_id: { type: 'string' } },
|
||||
required: ['invoice_id'],
|
||||
}
|
||||
|
||||
const projected = projectToolInputSchema({ name: 'gnubok_send_invoice', inputSchema })
|
||||
|
||||
expect(projected).not.toBe(inputSchema)
|
||||
expect(projected.properties).toEqual({
|
||||
invoice_id: { type: 'string' },
|
||||
company_id: expect.objectContaining({ type: 'string', format: 'uuid' }),
|
||||
})
|
||||
expect(inputSchema.properties).not.toHaveProperty('company_id')
|
||||
expect(projected.additionalProperties).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['gnubok_search_tools', 'gnubok_load_skill', 'gnubok_list_companies'])(
|
||||
'keeps the company-independent schema unchanged for %s',
|
||||
(name) => {
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
}
|
||||
|
||||
expect(isCompanyDependentTool(name)).toBe(false)
|
||||
expect(projectToolInputSchema({ name, inputSchema })).toBe(inputSchema)
|
||||
}
|
||||
)
|
||||
|
||||
it('extracts and strips a valid company_id before tool execution', () => {
|
||||
expect(
|
||||
extractRequestedCompany({ company_id: OTHER_COMPANY_ID, invoice_id: 'invoice-1' })
|
||||
).toEqual({
|
||||
requestedCompanyId: OTHER_COMPANY_ID,
|
||||
toolArgs: { invoice_id: 'invoice-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed company_id', () => {
|
||||
expect(() => extractRequestedCompany({ company_id: 'not-a-uuid' })).toThrow(
|
||||
expect.objectContaining({ code: 'VALIDATION_ERROR' })
|
||||
)
|
||||
})
|
||||
|
||||
it('checks membership and resolves the requested company role', async () => {
|
||||
const { client, chain } = membershipClient({
|
||||
data: { company_id: OTHER_COMPANY_ID, role: 'admin' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveMcpCompanyContext({
|
||||
supabase: client as never,
|
||||
userId: 'user-1',
|
||||
defaultCompanyId: DEFAULT_COMPANY_ID,
|
||||
requestedCompanyId: OTHER_COMPANY_ID,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
companyId: OTHER_COMPANY_ID,
|
||||
role: 'admin',
|
||||
isDefault: false,
|
||||
})
|
||||
expect(chain.eq).toHaveBeenCalledWith('user_id', 'user-1')
|
||||
expect(chain.eq).toHaveBeenCalledWith('company_id', OTHER_COMPANY_ID)
|
||||
expect(chain.is).toHaveBeenCalledWith('companies.archived_at', null)
|
||||
})
|
||||
|
||||
it('checks the API key default company when company_id is omitted', async () => {
|
||||
const { client, chain } = membershipClient({
|
||||
data: { company_id: DEFAULT_COMPANY_ID, role: 'owner' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveMcpCompanyContext({
|
||||
supabase: client as never,
|
||||
userId: 'user-1',
|
||||
defaultCompanyId: DEFAULT_COMPANY_ID,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
companyId: DEFAULT_COMPANY_ID,
|
||||
role: 'owner',
|
||||
isDefault: true,
|
||||
})
|
||||
expect(chain.eq).toHaveBeenCalledWith('company_id', DEFAULT_COMPANY_ID)
|
||||
})
|
||||
|
||||
it('rejects companies without a current non-archived membership', async () => {
|
||||
const { client } = membershipClient({ data: null, error: null })
|
||||
|
||||
await expect(
|
||||
resolveMcpCompanyContext({
|
||||
supabase: client as never,
|
||||
userId: 'user-1',
|
||||
defaultCompanyId: DEFAULT_COMPANY_ID,
|
||||
requestedCompanyId: OTHER_COMPANY_ID,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('fails closed when the membership lookup fails', async () => {
|
||||
const { client } = membershipClient({
|
||||
data: null,
|
||||
error: { message: 'database unavailable' },
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveMcpCompanyContext({
|
||||
supabase: client as never,
|
||||
userId: 'user-1',
|
||||
defaultCompanyId: DEFAULT_COMPANY_ID,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'INTERNAL_ERROR' })
|
||||
})
|
||||
|
||||
it('allows viewer reads but rejects viewer writes, approvals, and management', () => {
|
||||
const context = { companyId: OTHER_COMPANY_ID, role: 'viewer' as const, isDefault: false }
|
||||
|
||||
expect(() => assertMcpCompanyWriteAccess(context, 'reports:read')).not.toThrow()
|
||||
expect(() => assertMcpCompanyWriteAccess(context, undefined)).not.toThrow()
|
||||
expect(() => assertMcpCompanyWriteAccess(context, 'invoices:write')).toThrow(
|
||||
expect.objectContaining({ code: 'FORBIDDEN' })
|
||||
)
|
||||
expect(() => assertMcpCompanyWriteAccess(context, 'pending_operations:approve')).toThrow(
|
||||
expect.objectContaining({ code: 'FORBIDDEN' })
|
||||
)
|
||||
expect(() => assertMcpCompanyWriteAccess(context, 'webhooks:manage')).toThrow(
|
||||
expect.objectContaining({ code: 'FORBIDDEN' })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps company context in follow-up tool hints', () => {
|
||||
const next = {
|
||||
tool: 'gnubok_approve_pending_operation',
|
||||
description: 'Approve the operation',
|
||||
args: { operation_id: 'operation-1' },
|
||||
}
|
||||
|
||||
expect(addCompanyToNextHint(next, OTHER_COMPANY_ID)).toEqual({
|
||||
...next,
|
||||
args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
|
||||
})
|
||||
expect(addCompanyToTopLevelNext({ data: {}, next }, OTHER_COMPANY_ID)).toEqual({
|
||||
data: {},
|
||||
next: {
|
||||
...next,
|
||||
args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getUserCompanies: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getUserCompanies } from '@/lib/company/context'
|
||||
import { tools } from '../server'
|
||||
|
||||
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const ARCHIVED_COMPANY_ID = '33333333-3333-4333-8333-333333333333'
|
||||
const listCompaniesTool = tools.find((tool) => tool.name === 'gnubok_list_companies')!
|
||||
|
||||
describe('gnubok_list_companies', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('is a read-only companies:read discovery tool', () => {
|
||||
expect(listCompaniesTool).toBeDefined()
|
||||
expect(TOOL_SCOPE_MAP.gnubok_list_companies).toBe('companies:read')
|
||||
expect(listCompaniesTool.annotations).toMatchObject({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('lists every non-archived membership with role and marks the default', async () => {
|
||||
vi.mocked(getUserCompanies).mockResolvedValue([
|
||||
{
|
||||
company_id: DEFAULT_COMPANY_ID,
|
||||
role: 'owner',
|
||||
joined_at: '2026-01-01',
|
||||
companies: {
|
||||
id: DEFAULT_COMPANY_ID,
|
||||
name: 'Legal Default AB',
|
||||
org_number: '559000-0001',
|
||||
entity_type: 'AB',
|
||||
archived_at: null,
|
||||
created_at: '2026-01-01',
|
||||
},
|
||||
},
|
||||
{
|
||||
company_id: OTHER_COMPANY_ID,
|
||||
role: 'viewer',
|
||||
joined_at: '2026-02-01',
|
||||
companies: {
|
||||
id: OTHER_COMPANY_ID,
|
||||
name: 'Other Legal Name',
|
||||
org_number: null,
|
||||
entity_type: 'EF',
|
||||
archived_at: null,
|
||||
created_at: '2026-02-01',
|
||||
},
|
||||
},
|
||||
{
|
||||
company_id: ARCHIVED_COMPANY_ID,
|
||||
role: 'admin',
|
||||
joined_at: '2026-03-01',
|
||||
companies: {
|
||||
id: ARCHIVED_COMPANY_ID,
|
||||
name: 'Archived AB',
|
||||
org_number: '559000-0003',
|
||||
entity_type: 'AB',
|
||||
archived_at: '2026-06-01',
|
||||
created_at: '2026-03-01',
|
||||
},
|
||||
},
|
||||
] as never)
|
||||
|
||||
const rangeMock = vi.fn().mockResolvedValue({
|
||||
data: [{ company_id: DEFAULT_COMPANY_ID, company_name: 'Configured Default AB' }],
|
||||
error: null,
|
||||
})
|
||||
const orderMock = vi.fn(() => ({ range: rangeMock }))
|
||||
const inMock = vi.fn(() => ({ order: orderMock }))
|
||||
const supabase = {
|
||||
from: vi.fn(() => ({
|
||||
select: vi.fn(() => ({ in: inMock })),
|
||||
})),
|
||||
}
|
||||
|
||||
const result = (await listCompaniesTool.execute(
|
||||
{},
|
||||
DEFAULT_COMPANY_ID,
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as Record<string, unknown>
|
||||
|
||||
expect(getUserCompanies).toHaveBeenCalledWith(supabase, 'user-1')
|
||||
expect(inMock).toHaveBeenCalledWith('company_id', [DEFAULT_COMPANY_ID, OTHER_COMPANY_ID])
|
||||
expect(orderMock).toHaveBeenCalledWith('company_id', { ascending: true })
|
||||
expect(rangeMock).toHaveBeenCalledWith(0, 999)
|
||||
expect(result).toEqual({
|
||||
companies: [
|
||||
{
|
||||
company_id: DEFAULT_COMPANY_ID,
|
||||
name: 'Configured Default AB',
|
||||
org_number: '559000-0001',
|
||||
entity_type: 'AB',
|
||||
role: 'owner',
|
||||
is_default: true,
|
||||
},
|
||||
{
|
||||
company_id: OTHER_COMPANY_ID,
|
||||
name: 'Other Legal Name',
|
||||
org_number: null,
|
||||
entity_type: 'EF',
|
||||
role: 'viewer',
|
||||
is_default: false,
|
||||
},
|
||||
],
|
||||
count: 2,
|
||||
default_company_id: DEFAULT_COMPANY_ID,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
membership: {
|
||||
data: {
|
||||
company_id: '22222222-2222-4222-8222-222222222222',
|
||||
role: 'owner',
|
||||
} as Record<string, unknown> | null,
|
||||
error: null as { message: string } | null,
|
||||
},
|
||||
companyIds: [] as string[],
|
||||
hasCapability: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
scopes: ['companies:read', 'invoices:write', 'reports:read'],
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Test Key',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: vi.fn((table: string) => {
|
||||
if (table !== 'company_members') throw new Error(`Unexpected table: ${table}`)
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn((column: string, value: string) => {
|
||||
if (column === 'company_id') mocks.companyIds.push(value)
|
||||
return chain
|
||||
}),
|
||||
is: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn(async () => mocks.membership),
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
||||
return { ...actual, hasCapability: mocks.hasCapability }
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
|
||||
function toolCall(args: Record<string, unknown>): Request {
|
||||
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'gnubok_send_invoice', arguments: args },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function parseToolResult(response: Response) {
|
||||
const json = await response.json()
|
||||
const result = json.result as { isError?: boolean; content: Array<{ text: string }> }
|
||||
return {
|
||||
isError: result.isError === true,
|
||||
payload: JSON.parse(result.content[0].text) as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MCP multi-company dispatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
mocks.companyIds.length = 0
|
||||
mocks.membership = {
|
||||
data: { company_id: OTHER_COMPANY_ID, role: 'owner' },
|
||||
error: null,
|
||||
}
|
||||
mocks.hasCapability.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('routes a tool call to an accessible requested company', async () => {
|
||||
const result = await parseToolResult(
|
||||
await handleMcpRequest(
|
||||
toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.payload.error as Record<string, unknown>).capability_blocked).toBe(true)
|
||||
expect(mocks.companyIds).toEqual([OTHER_COMPANY_ID])
|
||||
expect(mocks.hasCapability).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
OTHER_COMPANY_ID,
|
||||
'email_send'
|
||||
)
|
||||
})
|
||||
|
||||
it('revalidates and uses the API key default company when company_id is omitted', async () => {
|
||||
mocks.membership.data = { company_id: DEFAULT_COMPANY_ID, role: 'admin' }
|
||||
|
||||
await handleMcpRequest(toolCall({ invoice_id: 'invoice-1' }))
|
||||
|
||||
expect(mocks.companyIds).toEqual([DEFAULT_COMPANY_ID])
|
||||
expect(mocks.hasCapability).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
DEFAULT_COMPANY_ID,
|
||||
'email_send'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a company the user does not belong to before capability or execution', async () => {
|
||||
mocks.membership.data = null
|
||||
|
||||
const result = await parseToolResult(
|
||||
await handleMcpRequest(
|
||||
toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.payload.error as Record<string, unknown>).code).toBe('NOT_FOUND')
|
||||
expect(mocks.hasCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects writes for a viewer in the selected company', async () => {
|
||||
mocks.membership.data = { company_id: OTHER_COMPANY_ID, role: 'viewer' }
|
||||
|
||||
const result = await parseToolResult(
|
||||
await handleMcpRequest(
|
||||
toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.payload.error as Record<string, unknown>).code).toBe('FORBIDDEN')
|
||||
expect(mocks.hasCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed company_id before querying membership', async () => {
|
||||
const result = await parseToolResult(
|
||||
await handleMcpRequest(
|
||||
toolCall({ invoice_id: 'invoice-1', company_id: 'not-a-uuid' })
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.payload.error as Record<string, unknown>).code).toBe('VALIDATION_ERROR')
|
||||
expect(mocks.companyIds).toEqual([])
|
||||
expect(mocks.hasCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools, deriveToolMeta } from '../server'
|
||||
import { projectToolInputSchema } from '../company-routing'
|
||||
|
||||
describe('tools/list payload size guard', () => {
|
||||
it('keeps the projected tools/list payload under the context-budget ceiling', () => {
|
||||
@@ -12,7 +13,7 @@ describe('tools/list payload size guard', () => {
|
||||
name: t.name,
|
||||
...(t.title ? { title: t.title } : {}),
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
inputSchema: projectToolInputSchema(t),
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
@@ -20,7 +21,7 @@ describe('tools/list payload size guard', () => {
|
||||
})
|
||||
const payload = JSON.stringify({ tools: projection })
|
||||
const approxTokens = Math.round(payload.length / 4)
|
||||
// Ceiling progression: 20K → 25K → 30K → 31K → 31.5K → 32K → 36K.
|
||||
// Ceiling progression: 20K to 25K to 30K to 31K to 31.5K to 32K to 36K.
|
||||
// * 20K → 25K when item 8 of the agent-native API plan landed
|
||||
// (additionalProperties: false on all inputSchemas + period_status in the
|
||||
// staged operation envelope).
|
||||
@@ -110,9 +111,14 @@ describe('tools/list payload size guard', () => {
|
||||
// gnubok_get_vacation_balance (ledger read) + gnubok_close_vacation_year
|
||||
// (staged HIGH semesterårsavslut with STAGED_OPERATION_SCHEMA + _meta).
|
||||
// Fortnox gap category E closed; both schemas already minimal.
|
||||
// * 51K to 54K for stateless multi-company MCP routing. Every
|
||||
// company-dependent tool must expose the optional company_id input so
|
||||
// the client can target another authorized company without shared
|
||||
// mutable connection state. The repeated property is intentionally
|
||||
// minimal; gnubok_list_companies and initialize instructions explain it.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(51_000)
|
||||
expect(approxTokens).toBeLessThan(54_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
scopes: ['transactions:read', 'transactions:write', 'customers:read', 'customers:write', 'invoices:read', 'invoices:write', 'suppliers:read', 'reports:read'],
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
@@ -179,7 +180,30 @@ describe('MCP Receipt Matcher', () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
supabase = mock.supabase
|
||||
enqueueMany = mock.enqueueMany
|
||||
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
|
||||
const membershipChain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return () => membershipChain
|
||||
},
|
||||
}
|
||||
)
|
||||
vi.mocked(createServiceClientNoCookies).mockReturnValue({
|
||||
...supabase,
|
||||
from: vi.fn((table: string) =>
|
||||
table === 'company_members' ? membershipChain : supabase.from(table)
|
||||
),
|
||||
} as never)
|
||||
})
|
||||
|
||||
// ── Protocol: initialize includes resources capability ──
|
||||
|
||||
@@ -55,6 +55,19 @@ describe('gnubok_search_tools', () => {
|
||||
const tool = result.tools[0]
|
||||
expect(tool).toHaveProperty('inputSchema')
|
||||
expect(tool).toHaveProperty('outputSchema')
|
||||
expect(
|
||||
(tool.inputSchema as { properties: Record<string, unknown> }).properties
|
||||
).toHaveProperty('company_id')
|
||||
})
|
||||
|
||||
it('does not add company_id to company-independent discovery tools', async () => {
|
||||
const result = await call({ detail: 'full', query: 'search tools', limit: 5 })
|
||||
const tool = result.tools.find((candidate) => candidate.name === 'gnubok_search_tools')
|
||||
|
||||
expect(tool).toBeDefined()
|
||||
expect(
|
||||
(tool!.inputSchema as { properties: Record<string, unknown> }).properties
|
||||
).not.toHaveProperty('company_id')
|
||||
})
|
||||
|
||||
it('filters by query keyword', async () => {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { isTenantWriteScope } from '../company-routing'
|
||||
|
||||
describe('MCP tool inputSchema strictness', () => {
|
||||
it('every tool inputSchema has additionalProperties: false at the top level', () => {
|
||||
@@ -22,4 +24,21 @@ describe('MCP tool inputSchema strictness', () => {
|
||||
.map((t) => t.name)
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('every tenant write tool has a scope that the central role guard can classify', () => {
|
||||
const allowedNonTenantWrites = new Set([
|
||||
'gnubok_audit_package',
|
||||
'gnubok_feedback',
|
||||
])
|
||||
const missing = tools
|
||||
.filter(
|
||||
(tool) =>
|
||||
tool.annotations.readOnlyHint !== true &&
|
||||
!isTenantWriteScope(TOOL_SCOPE_MAP[tool.name]) &&
|
||||
!allowedNonTenantWrites.has(tool.name)
|
||||
)
|
||||
.map((tool) => tool.name)
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
// Only reports:read: enough to call gnubok_get_trial_balance, NOT enough
|
||||
// to call gnubok_create_invoice (invoices:write). Drives the scope-denied test.
|
||||
scopes: ['reports:read'],
|
||||
@@ -35,6 +35,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
// filter has data to work against.
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'company_members') {
|
||||
return {
|
||||
select: vi.fn(() => {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
eq: vi.fn(() => chain),
|
||||
is: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
@@ -191,7 +209,7 @@ describe('mcp.tool_called telemetry', () => {
|
||||
expect(event.actorId).toBe('key-1')
|
||||
expect(event.actorLabel).toBe('Test Key')
|
||||
expect(event.userId).toBe('user-1')
|
||||
expect(event.companyId).toBe('company-1')
|
||||
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
|
||||
expect(event.requestId).toBe(1)
|
||||
// Real wall-clock: non-negative number
|
||||
expect(typeof event.latencyMs).toBe('number')
|
||||
@@ -393,7 +411,7 @@ describe('mcp.tools_list_called telemetry', () => {
|
||||
expect(event.toolCount).toBeLessThan(100)
|
||||
expect(event.actorType).toBe('api_key')
|
||||
expect(event.userId).toBe('user-1')
|
||||
expect(event.companyId).toBe('company-1')
|
||||
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
|
||||
expect(typeof event.latencyMs).toBe('number')
|
||||
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
@@ -496,7 +514,7 @@ describe('mcp.skill_loaded telemetry', () => {
|
||||
expect(event.actorType).toBe('api_key')
|
||||
expect(event.actorId).toBe('key-1')
|
||||
expect(event.userId).toBe('user-1')
|
||||
expect(event.companyId).toBe('company-1')
|
||||
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
|
||||
|
||||
// The pre-existing workflow-funnel event still fires for workflow tier.
|
||||
const wf = await workflowStartedPromise
|
||||
|
||||
@@ -29,6 +29,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
},
|
||||
},
|
||||
)
|
||||
const membershipChain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return () => membershipChain
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
@@ -42,7 +60,10 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
apiKeyName: 'Test Key',
|
||||
mode: 'test',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: (table: string) => (table === 'company_members' ? membershipChain : chain),
|
||||
rpc: () => chain,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
scopes: ['reports:read'],
|
||||
}),
|
||||
// Fully-chainable, awaitable proxy resolving to empty data: satisfies both
|
||||
@@ -38,7 +38,27 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
},
|
||||
},
|
||||
)
|
||||
return { from: () => makeChain() }
|
||||
const membershipChain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return () => membershipChain
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
from: (table: string) => (table === 'company_members' ? membershipChain : makeChain()),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
import type { CompanyRole } from '@/types'
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
const COMPANY_INDEPENDENT_TOOLS = new Set([
|
||||
'gnubok_search_tools',
|
||||
'gnubok_load_skill',
|
||||
'gnubok_list_companies',
|
||||
])
|
||||
|
||||
export const COMPANY_ID_INPUT_PROPERTY = {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Target company ID. Omit for default.',
|
||||
} as const
|
||||
|
||||
export interface McpCompanyContext {
|
||||
companyId: string
|
||||
role: CompanyRole
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
interface ToolSchemaSource {
|
||||
name: string
|
||||
inputSchema: Record<string, unknown>
|
||||
}
|
||||
|
||||
function codedError(
|
||||
code: 'VALIDATION_ERROR' | 'NOT_FOUND' | 'FORBIDDEN' | 'INTERNAL_ERROR',
|
||||
message: string
|
||||
) {
|
||||
return Object.assign(new Error(message), { code })
|
||||
}
|
||||
|
||||
function isCompanyRole(value: unknown): value is CompanyRole {
|
||||
return value === 'owner' || value === 'admin' || value === 'member' || value === 'viewer'
|
||||
}
|
||||
|
||||
export function isCompanyDependentTool(toolName: string): boolean {
|
||||
return !COMPANY_INDEPENDENT_TOOLS.has(toolName)
|
||||
}
|
||||
|
||||
export function isTenantWriteScope(scope: ApiKeyScope | undefined): boolean {
|
||||
return (
|
||||
scope?.endsWith(':write') === true ||
|
||||
scope?.endsWith(':approve') === true ||
|
||||
scope?.endsWith(':manage') === true
|
||||
)
|
||||
}
|
||||
|
||||
export function projectToolInputSchema(tool: ToolSchemaSource): Record<string, unknown> {
|
||||
if (!isCompanyDependentTool(tool.name)) return tool.inputSchema
|
||||
|
||||
const properties =
|
||||
tool.inputSchema.properties && typeof tool.inputSchema.properties === 'object'
|
||||
? (tool.inputSchema.properties as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
return {
|
||||
...tool.inputSchema,
|
||||
properties: {
|
||||
...properties,
|
||||
company_id: COMPANY_ID_INPUT_PROPERTY,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function extractRequestedCompany(
|
||||
rawArgs: Record<string, unknown>
|
||||
): { requestedCompanyId: string | undefined; toolArgs: Record<string, unknown> } {
|
||||
const { company_id: rawCompanyId, ...toolArgs } = rawArgs
|
||||
if (rawCompanyId === undefined) return { requestedCompanyId: undefined, toolArgs }
|
||||
if (typeof rawCompanyId !== 'string' || !UUID_PATTERN.test(rawCompanyId)) {
|
||||
throw codedError('VALIDATION_ERROR', 'company_id must be a valid UUID')
|
||||
}
|
||||
return { requestedCompanyId: rawCompanyId, toolArgs }
|
||||
}
|
||||
|
||||
export async function resolveMcpCompanyContext(args: {
|
||||
supabase: SupabaseClient
|
||||
userId: string
|
||||
defaultCompanyId: string
|
||||
requestedCompanyId?: string
|
||||
}): Promise<McpCompanyContext> {
|
||||
const companyId = args.requestedCompanyId ?? args.defaultCompanyId
|
||||
|
||||
const { data: membership, error } = await args.supabase
|
||||
.from('company_members')
|
||||
.select('company_id, role, companies!inner(archived_at)')
|
||||
.eq('user_id', args.userId)
|
||||
.eq('company_id', companyId)
|
||||
.is('companies.archived_at', null)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
throw codedError('INTERNAL_ERROR', `Failed to resolve company membership: ${error.message}`)
|
||||
}
|
||||
if (!membership) {
|
||||
throw codedError('NOT_FOUND', 'Company not found')
|
||||
}
|
||||
if (!isCompanyRole(membership.role)) {
|
||||
throw codedError('FORBIDDEN', 'Company membership has an unsupported role')
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
role: membership.role,
|
||||
isDefault: companyId === args.defaultCompanyId,
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMcpCompanyWriteAccess(
|
||||
context: McpCompanyContext,
|
||||
scope: ApiKeyScope | undefined
|
||||
): void {
|
||||
if (context.role === 'viewer' && isTenantWriteScope(scope)) {
|
||||
throw codedError('FORBIDDEN', 'Write permission required for this company')
|
||||
}
|
||||
}
|
||||
|
||||
export function addCompanyToNextHint(next: unknown, companyId: string): unknown {
|
||||
if (!next || typeof next !== 'object' || Array.isArray(next)) return next
|
||||
const hint = next as Record<string, unknown>
|
||||
if (typeof hint.tool !== 'string' || !isCompanyDependentTool(hint.tool)) return next
|
||||
const args =
|
||||
hint.args && typeof hint.args === 'object' && !Array.isArray(hint.args)
|
||||
? (hint.args as Record<string, unknown>)
|
||||
: {}
|
||||
return {
|
||||
...hint,
|
||||
args: { ...args, company_id: companyId },
|
||||
}
|
||||
}
|
||||
|
||||
export function addCompanyToTopLevelNext(result: unknown, companyId: string): unknown {
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)) return result
|
||||
const record = result as Record<string, unknown>
|
||||
if (!record.next) return result
|
||||
return {
|
||||
...record,
|
||||
next: addCompanyToNextHint(record.next, companyId),
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import type { McpResource } from './types'
|
||||
export const companyCurrentResource: McpResource = {
|
||||
uri: 'Accounted://company/current',
|
||||
name: 'Active Company',
|
||||
description: 'Per-company working memory: identity, active fiscal period, lock dates, entity counts, voucher series state, recent activity, approaching Swedish filing deadlines. Read this first when starting work on a company.',
|
||||
description: 'Working memory for the API key default company: identity, active fiscal period, lock dates, entity counts, voucher series state, recent activity, and filing deadlines. For another company, call gnubok_get_agent_briefing with company_id.',
|
||||
mimeType: 'application/json',
|
||||
read: async ({ supabase, companyId }) => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
@@ -58,6 +58,15 @@ import {
|
||||
IdempotencyKeyReuseError,
|
||||
} from '@/lib/api/idempotency'
|
||||
import { toToolError, type NextActionHint } from './tool-result'
|
||||
import {
|
||||
addCompanyToNextHint,
|
||||
addCompanyToTopLevelNext,
|
||||
assertMcpCompanyWriteAccess,
|
||||
extractRequestedCompany,
|
||||
isCompanyDependentTool,
|
||||
projectToolInputSchema,
|
||||
resolveMcpCompanyContext,
|
||||
} from './company-routing'
|
||||
import { findSupplierCandidates } from './supplier-candidates'
|
||||
import { assertNoPlaintextPersonnummer } from './staging-pii-guard'
|
||||
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||||
@@ -112,6 +121,7 @@ import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { commitPendingOperation } from '@/lib/pending-operations/commit'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { getUserCompanies } from '@/lib/company/context'
|
||||
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
|
||||
// which dispatches to this handler: no duplicate call needed here.
|
||||
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType } from '@/types'
|
||||
@@ -333,7 +343,7 @@ async function stagePendingOperation(
|
||||
message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`,
|
||||
preview: previewData,
|
||||
...(periodStatus ? { period_status: periodStatus } : {}),
|
||||
...(next ? { next } : {}),
|
||||
...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,7 +368,12 @@ async function stagePendingOperation(
|
||||
? `Replayed cached response for idempotency_key "${options.idempotencyKey}": already staged as pending_operation ${cachedOpId}. No new side-effects. ${buildApprovalGuidance(cachedOpId, riskLevel)}`
|
||||
: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`,
|
||||
...(cachedOpId
|
||||
? { approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: cachedOpId } } }
|
||||
? {
|
||||
approve: {
|
||||
tool: 'gnubok_approve_pending_operation',
|
||||
args: { operation_id: cachedOpId, company_id: companyId },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
|
||||
...(periodStatus ? { period_status: periodStatus } : {}),
|
||||
@@ -399,11 +414,11 @@ async function stagePendingOperation(
|
||||
message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.`,
|
||||
approve: {
|
||||
tool: 'gnubok_approve_pending_operation',
|
||||
args: { operation_id: data.id } as Record<string, unknown>,
|
||||
args: { operation_id: data.id, company_id: companyId } as Record<string, unknown>,
|
||||
},
|
||||
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
|
||||
...(periodStatus ? { period_status: periodStatus } : {}),
|
||||
...(next ? { next } : {}),
|
||||
...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
|
||||
} as const
|
||||
|
||||
if (options.idempotencyKey && requestHash) {
|
||||
@@ -1760,7 +1775,7 @@ export const tools: McpTool[] = [
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
scope: requiredScope,
|
||||
inputSchema: t.inputSchema,
|
||||
inputSchema: projectToolInputSchema(t),
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
@@ -1779,6 +1794,109 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_companies',
|
||||
title: 'List Companies',
|
||||
description: 'List every non-archived company this API-key user can access. Use company_id from this result on other tools; omit it there to use the API key default.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
companies: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
company_id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
org_number: { type: ['string', 'null'] },
|
||||
entity_type: { type: ['string', 'null'] },
|
||||
role: { type: 'string', enum: ['owner', 'admin', 'member', 'viewer'] },
|
||||
is_default: { type: 'boolean' },
|
||||
},
|
||||
required: ['company_id', 'name', 'org_number', 'entity_type', 'role', 'is_default'],
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
default_company_id: { type: ['string', 'null'] },
|
||||
},
|
||||
required: ['companies', 'count', 'default_company_id'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, defaultCompanyId, userId, supabase) {
|
||||
type CompanyRow = {
|
||||
id: string
|
||||
name: string
|
||||
org_number: string | null
|
||||
entity_type: string | null
|
||||
archived_at: string | null
|
||||
}
|
||||
type MembershipRow = {
|
||||
company_id: string
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer'
|
||||
companies: CompanyRow | CompanyRow[] | null
|
||||
}
|
||||
|
||||
const memberships = (await getUserCompanies(supabase, userId)) as unknown as MembershipRow[]
|
||||
const accessible = memberships.flatMap((membership) => {
|
||||
const company = Array.isArray(membership.companies)
|
||||
? membership.companies[0]
|
||||
: membership.companies
|
||||
return company && company.archived_at === null ? [{ membership, company }] : []
|
||||
})
|
||||
const companyIds = accessible.map(({ company }) => company.id)
|
||||
const displayNames = new Map<string, string>()
|
||||
|
||||
if (companyIds.length > 0) {
|
||||
try {
|
||||
const settings = await fetchAllRows<{ company_id: string; company_name: string | null }>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('company_id, company_name')
|
||||
.in('company_id', companyIds)
|
||||
.order('company_id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
for (const row of settings) {
|
||||
if (row.company_name) displayNames.set(row.company_id, row.company_name)
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('gnubok_list_companies display-name lookup failed', {
|
||||
error: error instanceof Error ? error.message : 'unknown',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const companies = accessible.map(({ membership, company }) => ({
|
||||
company_id: company.id,
|
||||
name: displayNames.get(company.id) ?? company.name,
|
||||
org_number: company.org_number,
|
||||
entity_type: company.entity_type,
|
||||
role: membership.role,
|
||||
is_default: company.id === defaultCompanyId,
|
||||
}))
|
||||
const hasAccessibleDefault = companies.some((company) => company.is_default)
|
||||
|
||||
return {
|
||||
companies,
|
||||
count: companies.length,
|
||||
default_company_id: hasAccessibleDefault ? defaultCompanyId : null,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_skills',
|
||||
title: 'List Domain Skills',
|
||||
@@ -2245,10 +2363,10 @@ export const tools: McpTool[] = [
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
description:
|
||||
'The single company every tool call in this session reads and writes. Confirm this is the entity the user means BEFORE any staged write: there is no per-call company switch; scope is fixed by the API key.',
|
||||
'The company selected for this call. Confirm it is the entity the user means before staging a write. Pass company_id on later calls to keep working in a non-default company.',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Deprecated: read company_id instead.' },
|
||||
company_id: { type: 'string', description: 'company_id this session is scoped to.' },
|
||||
company_id: { type: 'string', description: 'company_id selected for this call.' },
|
||||
name: { type: ['string', 'null'] },
|
||||
org_number: { type: ['string', 'null'] },
|
||||
entity_type: { type: ['string', 'null'], description: 'e.g. "aktiebolag", "enskild_firma". Null if unset.' },
|
||||
@@ -2493,10 +2611,10 @@ export const tools: McpTool[] = [
|
||||
.select('full_name')
|
||||
.eq('id', userId)
|
||||
.maybeSingle(),
|
||||
// Company identity so the agent can confirm WHICH entity it operates on
|
||||
// before any write. Scope is fixed by the API key: there is no per-call
|
||||
// switch: so this is the session's "whoami for the company". Best-effort:
|
||||
// a failed read still yields a company block with at least the id.
|
||||
// Company identity so the agent can confirm which entity it operates on
|
||||
// before any write. The dispatcher has already resolved and authorized
|
||||
// the optional per-call company_id. A failed read still yields a company
|
||||
// block with at least the id.
|
||||
supabase
|
||||
.from('companies')
|
||||
.select('name, org_number, entity_type')
|
||||
@@ -12006,20 +12124,6 @@ export const tools: McpTool[] = [
|
||||
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
|
||||
const assetIds = Array.isArray(args.asset_ids) ? (args.asset_ids as string[]) : undefined
|
||||
|
||||
// Mirror the HTTP route's `requireWrite: true` guard so a viewer-role
|
||||
// member can't post depreciation through the MCP surface. RLS would
|
||||
// reject the underlying INSERTs anyway, but failing fast here
|
||||
// produces a much cleaner error than the cascaded RLS rejection.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
if (!membership || membership.role === 'viewer') {
|
||||
throw new Error('Write permission required')
|
||||
}
|
||||
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
|
||||
@@ -12629,7 +12733,7 @@ function emitToolCallTelemetry(payload: {
|
||||
success: boolean
|
||||
isError: boolean
|
||||
errorCode: string | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorMessage: string | null
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
@@ -12957,6 +13061,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
'',
|
||||
'Discovery:',
|
||||
'• tools/list returns the full schema for every tool. To narrow a large catalog, call gnubok_search_tools(query="…"): it ranks tools by relevance; pass detail="name"|"summary"|"full" to control payload size.',
|
||||
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`,
|
||||
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
|
||||
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
|
||||
'',
|
||||
'Common workflows:',
|
||||
@@ -13014,7 +13120,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
name: t.name,
|
||||
...(t.title ? { title: t.title } : {}),
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
inputSchema: projectToolInputSchema(t),
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
@@ -13026,7 +13132,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
|
||||
case 'tools/call': {
|
||||
const toolName = (params as Record<string, unknown>)?.name as string
|
||||
const toolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
|
||||
const rawToolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
@@ -13085,12 +13191,55 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
let toolArgs: Record<string, unknown>
|
||||
let effectiveCompanyId = companyId
|
||||
const companyRoutingStartedAt = Date.now()
|
||||
try {
|
||||
const extracted = extractRequestedCompany(rawToolArgs)
|
||||
toolArgs = extracted.toolArgs
|
||||
|
||||
if (isCompanyDependentTool(toolName)) {
|
||||
const companyContext = await resolveMcpCompanyContext({
|
||||
supabase,
|
||||
userId,
|
||||
defaultCompanyId: companyId,
|
||||
requestedCompanyId: extracted.requestedCompanyId,
|
||||
})
|
||||
assertMcpCompanyWriteAccess(companyContext, requiredScope)
|
||||
effectiveCompanyId = companyContext.companyId
|
||||
}
|
||||
} catch (err) {
|
||||
const structured = toToolError(err, { toolName })
|
||||
emitToolCallTelemetry({
|
||||
tool: toolName,
|
||||
requiredScope: requiredScope ?? null,
|
||||
actor,
|
||||
latencyMs: Date.now() - companyRoutingStartedAt,
|
||||
success: false,
|
||||
isError: true,
|
||||
errorCode: structured.error.code,
|
||||
errorKind: 'company_access_denied',
|
||||
errorMessage: structured.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
// Keep denied attempts attributed to the key default. An arbitrary,
|
||||
// unauthorized target must never create tenant telemetry there.
|
||||
companyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
||||
isError: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Enforce the capability paywall: the MCP/agent path is a paid chokepoint
|
||||
// just like the HTTP routes (send_invoice → email_send, the two SKV
|
||||
// submissions → skatteverket). Fail-closed; self-hosted short-circuits to
|
||||
// all-on inside hasCapability. Blocks before any pending op is staged.
|
||||
const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName]
|
||||
if (requiredCapability && !(await hasCapability(supabase, companyId, requiredCapability))) {
|
||||
if (requiredCapability && !(await hasCapability(supabase, effectiveCompanyId, requiredCapability))) {
|
||||
const capError = { error: capabilityBlockedError(requiredCapability) }
|
||||
emitToolCallTelemetry({
|
||||
tool: toolName,
|
||||
@@ -13104,7 +13253,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
errorMessage: capError.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
companyId: effectiveCompanyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
@@ -13144,7 +13293,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
errorMessage: blocked.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
companyId: effectiveCompanyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
@@ -13158,7 +13307,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
// Detect if THIS call follows the previous call's `next` hint: must
|
||||
// run before execute() so we don't double-store on this call. Emits
|
||||
// mcp.next_hint_followed when the agent's behaviour matches the hint.
|
||||
checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, companyId)
|
||||
checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, effectiveCompanyId)
|
||||
|
||||
const callStartedAt = Date.now()
|
||||
try {
|
||||
@@ -13167,7 +13316,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
if (toolName === 'gnubok_search_tools') {
|
||||
(toolArgs as Record<string, unknown>).__keyScopes = keyScopes
|
||||
}
|
||||
const result = await tool.execute(toolArgs, companyId, userId, supabase, actor)
|
||||
const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor)
|
||||
const result = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
|
||||
const latencyMs = Date.now() - callStartedAt
|
||||
const response: Record<string, unknown> = {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
@@ -13208,7 +13358,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
errorMessage: null,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
companyId: effectiveCompanyId,
|
||||
})
|
||||
return NextResponse.json(jsonRpc(id ?? null, response))
|
||||
} catch (err) {
|
||||
@@ -13229,7 +13379,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
errorMessage: structured.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
companyId: effectiveCompanyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
|
||||
@@ -1410,6 +1410,24 @@ describe('UpdateSettingsSchema', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reminder day thresholds', () => {
|
||||
it('accepts integer thresholds from 1 through 365', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
reminder_days_level_1: 7,
|
||||
reminder_days_level_2: 21,
|
||||
reminder_days_level_3: 365,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it.each([0, 366, 1.5])('rejects invalid threshold %s', (days) => {
|
||||
const result = UpdateSettingsSchema.safeParse({ reminder_days_level_1: days })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invoice_email_texts', () => {
|
||||
it('accepts a valid nested partial', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
|
||||
+32
-1
@@ -681,9 +681,34 @@ export const CreateCustomerSchema = z.object({
|
||||
language: z.enum(['sv', 'en']).optional(),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
notes: z.string().optional(),
|
||||
}).superRefine((customer, ctx) => {
|
||||
if (customer.personal_number && customer.customer_type !== 'individual') {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['personal_number'],
|
||||
message: 'Personal number is only allowed for individual customers',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const UpdateCustomerSchema = CreateCustomerSchema.partial()
|
||||
export const UpdateCustomerSchema = z.object({
|
||||
name: z.string().min(1, 'Customer name is required').optional(),
|
||||
customer_type: CustomerTypeSchema.optional(),
|
||||
customer_number: z.string().trim().max(32).nullable().optional(),
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
personal_number: z.string().regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number').nullable().optional(),
|
||||
language: z.enum(['sv', 'en']).optional(),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Supplier schemas
|
||||
@@ -786,6 +811,9 @@ export const CreateSupplierInvoiceItemSchema = z.object({
|
||||
|
||||
export const CreateSupplierInvoiceSchema = z.object({
|
||||
supplier_id: uuid,
|
||||
// Optional invoice PDF/image already stored in the WORM document archive.
|
||||
// The route verifies company ownership and that the document is unused.
|
||||
document_id: uuid.optional(),
|
||||
supplier_invoice_number: z.string().min(1, 'Supplier invoice number is required'),
|
||||
invoice_date: isoDate,
|
||||
due_date: isoDate,
|
||||
@@ -1493,6 +1521,9 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_footer_text: z.string().max(500).nullable().optional(),
|
||||
// Automation
|
||||
send_invoice_reminders: z.boolean().optional(),
|
||||
reminder_days_level_1: z.number().int().min(1).max(365).optional(),
|
||||
reminder_days_level_2: z.number().int().min(1).max(365).optional(),
|
||||
reminder_days_level_3: z.number().int().min(1).max(365).optional(),
|
||||
// Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
|
||||
reminder_fee_enabled: z.boolean().optional(),
|
||||
reminder_fee_amount: z
|
||||
|
||||
@@ -158,6 +158,8 @@ export const SCOPE_GROUPS = [
|
||||
|
||||
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
|
||||
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Companies
|
||||
gnubok_list_companies: 'companies:read',
|
||||
// Transactions
|
||||
gnubok_list_uncategorized_transactions: 'transactions:read',
|
||||
gnubok_list_transactions_without_documents: 'transactions:read',
|
||||
|
||||
@@ -453,6 +453,45 @@ describe('createCreditNoteJournalEntry: per-line VAT', () => {
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['rot' as const, 3000, 9500],
|
||||
['rut' as const, 5000, 7500],
|
||||
])('reverses the %s receivable split across 1510 and 1513', async (deductionType, taxCredit, customerCredit) => {
|
||||
const creditNote = makeInvoice({
|
||||
invoice_number: 'KR-1002',
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
total: -12500,
|
||||
vat_treatment: 'standard_25',
|
||||
items: [
|
||||
makeItem({
|
||||
quantity: -1,
|
||||
unit_price: 10000,
|
||||
line_total: -10000,
|
||||
vat_rate: 25,
|
||||
vat_amount: -2500,
|
||||
deduction_type: deductionType,
|
||||
dimensions: { '6': 'P001' },
|
||||
}),
|
||||
],
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
|
||||
await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
|
||||
expect(input.lines.find((line) => line.account_number === '1510')?.credit_amount)
|
||||
.toBe(customerCredit)
|
||||
const line1513 = input.lines.find((line) => line.account_number === '1513')
|
||||
expect(line1513?.credit_amount).toBe(taxCredit)
|
||||
expect(line1513?.debit_amount).toBe(0)
|
||||
expect(line1513?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
|
||||
|
||||
const totalDebit = input.lines.reduce((sum, line) => sum + line.debit_amount, 0)
|
||||
const totalCredit = input.lines.reduce((sum, line) => sum + line.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInvoiceCashEntry: per-line VAT', () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ function makeInvoiceInput(overrides: Partial<{
|
||||
currency: string
|
||||
exchange_rate: number | null
|
||||
vat_treatment: VatTreatment
|
||||
credited_invoice_id: string | null
|
||||
items: InvoiceItem[]
|
||||
default_dimensions: Record<string, string> | null
|
||||
}> = {}) {
|
||||
@@ -77,6 +78,71 @@ describe('proposeSendLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('credit note uses positive amounts on the reversed sides', () => {
|
||||
const lines = proposeSendLines({
|
||||
invoice: makeInvoiceInput({
|
||||
invoice_number: 'KR-2025-001',
|
||||
credited_invoice_id: 'invoice-1',
|
||||
total: -12500,
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
items: [
|
||||
makeItem({ quantity: -1, line_total: -10000, vat_amount: -2500 }),
|
||||
],
|
||||
}),
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines).toEqual([
|
||||
{
|
||||
account_number: '1510',
|
||||
debit_amount: '',
|
||||
credit_amount: '12500',
|
||||
line_description: 'Kreditfaktura KR-2025-001',
|
||||
},
|
||||
{
|
||||
account_number: '3001',
|
||||
debit_amount: '10000',
|
||||
credit_amount: '',
|
||||
line_description: 'Kreditfaktura KR-2025-001',
|
||||
},
|
||||
{
|
||||
account_number: '2611',
|
||||
debit_amount: '2500',
|
||||
credit_amount: '',
|
||||
line_description: 'Moms kreditfaktura 25%',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('credit-note preview reverses the ROT receivable split', () => {
|
||||
const lines = proposeSendLines({
|
||||
invoice: makeInvoiceInput({
|
||||
invoice_number: 'KR-2025-002',
|
||||
credited_invoice_id: 'invoice-2',
|
||||
total: -12500,
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
items: [
|
||||
makeItem({
|
||||
quantity: -1,
|
||||
line_total: -10000,
|
||||
vat_amount: -2500,
|
||||
deduction_type: 'rot',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines.find((line) => line.account_number === '1510')?.credit_amount).toBe('9500')
|
||||
expect(lines.find((line) => line.account_number === '1513')?.credit_amount).toBe('3000')
|
||||
expect(lines.reduce((sum, line) => sum + (parseFloat(line.debit_amount) || 0), 0))
|
||||
.toBe(12500)
|
||||
expect(lines.reduce((sum, line) => sum + (parseFloat(line.credit_amount) || 0), 0))
|
||||
.toBe(12500)
|
||||
})
|
||||
|
||||
describe('dimensions propagation (PR7)', () => {
|
||||
const bag = { '1': 'KS01', '6': 'P001' }
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { generateSalesVatLines } from './vat-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
@@ -258,6 +259,7 @@ function generateRotRutLines(
|
||||
currency?: string | null,
|
||||
exchangeRate?: number | null,
|
||||
defaultDimensions?: LineDimensions,
|
||||
side: 'debit' | 'credit' = 'debit',
|
||||
): { lines: CreateJournalEntryLineInput[]; totalSek: number } {
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const isForeign = currency != null && currency !== 'SEK'
|
||||
@@ -276,8 +278,8 @@ function generateRotRutLines(
|
||||
if (!item.deduction_type) continue
|
||||
// Recompute server-side to defend against tampered client values.
|
||||
const amount = computeDeduction({
|
||||
unit_price: item.unit_price,
|
||||
quantity: item.quantity,
|
||||
unit_price: side === 'credit' ? Math.abs(item.unit_price) : item.unit_price,
|
||||
quantity: side === 'credit' ? Math.abs(item.quantity) : item.quantity,
|
||||
deduction_type: item.deduction_type,
|
||||
})
|
||||
if (amount <= 0) continue
|
||||
@@ -287,9 +289,11 @@ function generateRotRutLines(
|
||||
const kind = item.deduction_type === 'rot' ? 'ROT' : 'RUT'
|
||||
lines.push({
|
||||
account_number: '1513',
|
||||
debit_amount: amountSek,
|
||||
credit_amount: 0,
|
||||
line_description: `${kind}-avdrag faktura ${invoiceTagText}`,
|
||||
debit_amount: side === 'debit' ? amountSek : 0,
|
||||
credit_amount: side === 'credit' ? amountSek : 0,
|
||||
line_description: side === 'credit'
|
||||
? `${kind}-avdrag kreditfaktura ${invoiceTagText}`
|
||||
: `${kind}-avdrag faktura ${invoiceTagText}`,
|
||||
// Per-item line: carries the item's merged bag like its revenue line.
|
||||
dimensions: mergeDimensionBags(defaultDimensions, item.dimensions),
|
||||
})
|
||||
@@ -648,15 +652,30 @@ export async function createCreditNoteJournalEntry(
|
||||
|
||||
lines.push(...debitLines)
|
||||
|
||||
// Credit: Kundfordringar, balance guarantee: credit = sum of all debit lines
|
||||
// ROT/RUT reverses the exact receivable split used by the original invoice:
|
||||
// 1510 for the customer portion and 1513 for the Skatteverket portion.
|
||||
const rotRut = creditNote.items && creditNote.items.length > 0
|
||||
? generateRotRutLines(
|
||||
creditNote.items,
|
||||
tag,
|
||||
creditNote.currency,
|
||||
creditNote.exchange_rate,
|
||||
defaultDimensions,
|
||||
'credit',
|
||||
)
|
||||
: { lines: [], totalSek: 0 }
|
||||
|
||||
// Credit: Kundfordringar, balance guarantee: 1510 + 1513 equals debits.
|
||||
const totalDebits = debitLines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const customerReceivable = roundOre(totalDebits - rotRut.totalSek)
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalDebits * 100) / 100,
|
||||
credit_amount: customerReceivable,
|
||||
line_description: `Kreditfaktura ${tag}`,
|
||||
dimensions: defaultDimensions,
|
||||
})
|
||||
lines.push(...rotRut.lines)
|
||||
|
||||
const baseDescription = buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id)
|
||||
const input: CreateJournalEntryInput = {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { resolveSekAmount } from './currency-utils'
|
||||
import { getRevenueAccount, getOutputVatAccount } from './invoice-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
|
||||
|
||||
@@ -22,6 +24,7 @@ export interface ProposeSendLinesInput {
|
||||
currency: string
|
||||
exchange_rate?: number | null
|
||||
vat_treatment: VatTreatment
|
||||
credited_invoice_id?: string | null
|
||||
items?: InvoiceItem[]
|
||||
/**
|
||||
* Dimensions PR7: the invoice's default bag, stamped on every proposed
|
||||
@@ -47,13 +50,51 @@ function toFormAmount(n: number): string {
|
||||
*/
|
||||
export function proposeSendLines(input: ProposeSendLinesInput): FormLine[] {
|
||||
const { invoice, entityType } = input
|
||||
const proposedLines = invoice.credited_invoice_id
|
||||
? buildCreditNoteLines(invoice, entityType)
|
||||
: buildSendLines(invoice, entityType)
|
||||
const lines: FormLine[] = stampProposalDimensions(
|
||||
buildSendLines(invoice, entityType),
|
||||
proposedLines,
|
||||
invoice.default_dimensions
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
function absoluteOptional(amount: number | null | undefined): number | null | undefined {
|
||||
return amount == null ? amount : Math.abs(amount)
|
||||
}
|
||||
|
||||
function buildCreditNoteLines(
|
||||
invoice: ProposeSendLinesInput['invoice'],
|
||||
entityType: EntityType,
|
||||
): FormLine[] {
|
||||
const absoluteInvoice: ProposeSendLinesInput['invoice'] = {
|
||||
...invoice,
|
||||
total: Math.abs(invoice.total),
|
||||
total_sek: absoluteOptional(invoice.total_sek),
|
||||
subtotal: Math.abs(invoice.subtotal),
|
||||
subtotal_sek: absoluteOptional(invoice.subtotal_sek),
|
||||
vat_amount: Math.abs(invoice.vat_amount),
|
||||
vat_amount_sek: absoluteOptional(invoice.vat_amount_sek),
|
||||
items: invoice.items?.map((item) => ({
|
||||
...item,
|
||||
quantity: Math.abs(item.quantity),
|
||||
line_total: Math.abs(item.line_total),
|
||||
vat_amount: item.vat_amount == null ? item.vat_amount : Math.abs(item.vat_amount),
|
||||
})),
|
||||
}
|
||||
|
||||
return buildSendLines(absoluteInvoice, entityType).map((line) => ({
|
||||
...line,
|
||||
debit_amount: line.credit_amount,
|
||||
credit_amount: line.debit_amount,
|
||||
line_description: line.line_description
|
||||
.replace('Försäljning faktura', 'Kreditfaktura')
|
||||
.replace('Utgående moms faktura', 'Moms kreditfaktura')
|
||||
.replace('Utgående moms', 'Moms kreditfaktura'),
|
||||
}))
|
||||
}
|
||||
|
||||
function stampProposalDimensions(
|
||||
lines: FormLine[],
|
||||
bag?: Record<string, string> | null
|
||||
@@ -164,19 +205,41 @@ function buildSendLines(
|
||||
}
|
||||
}
|
||||
|
||||
// Debit: 1510 Kundfordringar, balance guarantee
|
||||
const deductionLines: FormLine[] = []
|
||||
let deductionTotal = 0
|
||||
for (const item of invoice.items ?? []) {
|
||||
if (!item.deduction_type) continue
|
||||
const deduction = computeDeduction({
|
||||
unit_price: item.unit_price,
|
||||
quantity: item.quantity,
|
||||
deduction_type: item.deduction_type,
|
||||
})
|
||||
const amountSek = roundOre(toSek(deduction))
|
||||
if (amountSek <= 0) continue
|
||||
deductionTotal = roundOre(deductionTotal + amountSek)
|
||||
deductionLines.push({
|
||||
account_number: '1513',
|
||||
debit_amount: toFormAmount(amountSek),
|
||||
credit_amount: '',
|
||||
line_description: `${item.deduction_type === 'rot' ? 'ROT' : 'RUT'}-avdrag faktura ${invoice.invoice_number ?? ''}`.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
// Debit: 1510 customer portion plus 1513 Skatteverket portion.
|
||||
const totalCredits = creditLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
||||
const debitAmount = isForeign
|
||||
? Math.round(totalCredits * 100) / 100
|
||||
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
|
||||
const customerReceivable = roundOre(debitAmount - deductionTotal)
|
||||
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: toFormAmount(debitAmount),
|
||||
debit_amount: toFormAmount(customerReceivable),
|
||||
credit_amount: '',
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
lines.push(...deductionLines)
|
||||
lines.push(...creditLines)
|
||||
|
||||
return lines
|
||||
|
||||
+21
-19
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { cookies } from 'next/headers'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
@@ -154,26 +155,27 @@ export async function getUserCompanies(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
) {
|
||||
const { data, error } = await supabase
|
||||
.from('company_members')
|
||||
.select(`
|
||||
company_id,
|
||||
role,
|
||||
joined_at,
|
||||
companies:company_id (
|
||||
return fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('company_members')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
org_number,
|
||||
entity_type,
|
||||
archived_at,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.order('joined_at', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
company_id,
|
||||
role,
|
||||
joined_at,
|
||||
companies:company_id (
|
||||
id,
|
||||
name,
|
||||
org_number,
|
||||
entity_type,
|
||||
archived_at,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,23 @@ describe('getJournalEntryUnderlagReferences', () => {
|
||||
expect(refs).toEqual([{ type: 'supplier_invoice', id: 'si-1', number: 'LF-001' }])
|
||||
})
|
||||
|
||||
it('surfaces the retained PDF through a supplier payment reference', async () => {
|
||||
const refs = await run([
|
||||
{ data: [] }, // 1. invoices direct
|
||||
{ data: [] }, // 2. invoice_payments
|
||||
{ data: [] }, // 4. supplier registration
|
||||
{ data: [{ id: 'si-1', supplier_invoice_number: 'LF-001', document_id: 'doc-1' }] }, // 5. supplier payment
|
||||
{ data: [{ supplier_invoice_id: 'si-1' }] }, // 6. supplier_invoice_payments
|
||||
])
|
||||
|
||||
expect(refs).toEqual([{
|
||||
type: 'supplier_invoice',
|
||||
id: 'si-1',
|
||||
number: 'LF-001',
|
||||
document_id: 'doc-1',
|
||||
}])
|
||||
})
|
||||
|
||||
it('returns nothing when no invoice is linked (warning legitimately stays)', async () => {
|
||||
const refs = await run([
|
||||
{ data: [] }, // 1. invoices direct
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
/**
|
||||
* A followable reference from a verifikation back to its underlag: the customer
|
||||
@@ -20,6 +21,8 @@ export interface UnderlagReference {
|
||||
id: string
|
||||
/** invoice_number / supplier_invoice_number: the UI builds the label from this. */
|
||||
number: string
|
||||
/** Retained source document owned by a referenced supplier invoice, if any. */
|
||||
document_id?: string
|
||||
}
|
||||
|
||||
interface InvoiceRow {
|
||||
@@ -30,6 +33,7 @@ interface InvoiceRow {
|
||||
interface SupplierInvoiceRow {
|
||||
id: string
|
||||
supplier_invoice_number: string
|
||||
document_id?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,21 +56,21 @@ export async function getJournalEntryUnderlagReferences(
|
||||
const invoices = new Map<string, string>()
|
||||
|
||||
// Direct link (faktureringsmetod registration, or invoices.journal_entry_id).
|
||||
const { data: directInvoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
const directInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
||||
supabase.from('invoices').select('id, invoice_number')
|
||||
.eq('company_id', companyId).eq('journal_entry_id', journalEntryId)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const inv of (directInvoices ?? []) as InvoiceRow[]) {
|
||||
invoices.set(inv.id, inv.invoice_number)
|
||||
}
|
||||
|
||||
// Payment rows (kontantmetod inbetalning, partial payments) → invoice_payments.
|
||||
const { data: paymentRows } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
const paymentRows = await fetchAllRows<{ id: string; invoice_id: string | null }>(({ from, to }) =>
|
||||
supabase.from('invoice_payments').select('id, invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId).order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
const paymentInvoiceIds = new Set<string>()
|
||||
for (const row of (paymentRows ?? []) as { invoice_id: string | null }[]) {
|
||||
@@ -74,11 +78,11 @@ export async function getJournalEntryUnderlagReferences(
|
||||
}
|
||||
|
||||
if (paymentInvoiceIds.size > 0) {
|
||||
const { data: paidInvoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', Array.from(paymentInvoiceIds))
|
||||
const paidInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
||||
supabase.from('invoices').select('id, invoice_number')
|
||||
.eq('company_id', companyId).in('id', Array.from(paymentInvoiceIds))
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const inv of (paidInvoices ?? []) as InvoiceRow[]) {
|
||||
invoices.set(inv.id, inv.invoice_number)
|
||||
@@ -86,35 +90,41 @@ export async function getJournalEntryUnderlagReferences(
|
||||
}
|
||||
|
||||
// --- Supplier invoices ---------------------------------------------------
|
||||
const supplierInvoices = new Map<string, string>()
|
||||
const supplierInvoices = new Map<string, { number: string; documentId?: string }>()
|
||||
|
||||
// Registration booking (accrual) on the invoice itself.
|
||||
const { data: registrationLinks } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('registration_journal_entry_id', journalEntryId)
|
||||
const registrationLinks = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
|
||||
supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
|
||||
.eq('company_id', companyId).eq('registration_journal_entry_id', journalEntryId)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const si of (registrationLinks ?? []) as SupplierInvoiceRow[]) {
|
||||
supplierInvoices.set(si.id, si.supplier_invoice_number)
|
||||
supplierInvoices.set(si.id, {
|
||||
number: si.supplier_invoice_number,
|
||||
...(si.document_id ? { documentId: si.document_id } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// Payment booking on the invoice itself.
|
||||
const { data: paymentLinks } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('payment_journal_entry_id', journalEntryId)
|
||||
const paymentLinks = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
|
||||
supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
|
||||
.eq('company_id', companyId).eq('payment_journal_entry_id', journalEntryId)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const si of (paymentLinks ?? []) as SupplierInvoiceRow[]) {
|
||||
supplierInvoices.set(si.id, si.supplier_invoice_number)
|
||||
supplierInvoices.set(si.id, {
|
||||
number: si.supplier_invoice_number,
|
||||
...(si.document_id ? { documentId: si.document_id } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// Partial-payment rows → supplier_invoice_payments.
|
||||
const { data: supplierPaymentRows } = await supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('supplier_invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
const supplierPaymentRows = await fetchAllRows<{ id: string; supplier_invoice_id: string | null }>(
|
||||
({ from, to }) => supabase.from('supplier_invoice_payments').select('id, supplier_invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId).order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
const supplierPaymentIds = new Set<string>()
|
||||
for (const row of (supplierPaymentRows ?? []) as { supplier_invoice_id: string | null }[]) {
|
||||
@@ -124,20 +134,30 @@ export async function getJournalEntryUnderlagReferences(
|
||||
}
|
||||
|
||||
if (supplierPaymentIds.size > 0) {
|
||||
const { data: paidSupplierInvoices } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', Array.from(supplierPaymentIds))
|
||||
const paidSupplierInvoices = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
|
||||
supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
|
||||
.eq('company_id', companyId).in('id', Array.from(supplierPaymentIds))
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const si of (paidSupplierInvoices ?? []) as SupplierInvoiceRow[]) {
|
||||
supplierInvoices.set(si.id, si.supplier_invoice_number)
|
||||
supplierInvoices.set(si.id, {
|
||||
number: si.supplier_invoice_number,
|
||||
...(si.document_id ? { documentId: si.document_id } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Assemble ------------------------------------------------------------
|
||||
const references: UnderlagReference[] = []
|
||||
for (const [id, number] of invoices) references.push({ type: 'invoice', id, number })
|
||||
for (const [id, number] of supplierInvoices) references.push({ type: 'supplier_invoice', id, number })
|
||||
for (const [id, supplierInvoice] of supplierInvoices) {
|
||||
references.push({
|
||||
type: 'supplier_invoice',
|
||||
id,
|
||||
number: supplierInvoice.number,
|
||||
...(supplierInvoice.documentId ? { document_id: supplierInvoice.documentId } : {}),
|
||||
})
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Display a personal identity number without exposing birth date or full ID.
|
||||
*/
|
||||
export function maskCustomerPersonalNumber(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
const last4 = value.replace(/\D/g, '').slice(-4)
|
||||
return last4.length === 4 ? `********-${last4}` : null
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number'
|
||||
|
||||
export function encryptCustomerPersonalNumber(value: string | null | undefined): string | null {
|
||||
return value ? encryptPersonnummer(value) : null
|
||||
}
|
||||
|
||||
export function maskStoredCustomerPersonalNumber(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
if (/^(\d{6}|\d{8})[-+]?\d{4}$/.test(value)) {
|
||||
return maskCustomerPersonalNumber(value)
|
||||
}
|
||||
return maskCustomerPersonalNumber(decryptPersonnummer(value))
|
||||
}
|
||||
|
||||
export function maskCustomerRow<T extends { personal_number?: string | null }>(row: T): T {
|
||||
return {
|
||||
...row,
|
||||
personal_number: maskStoredCustomerPersonalNumber(row.personal_number),
|
||||
}
|
||||
}
|
||||
@@ -355,10 +355,34 @@ export function generateReminderEmailSubject(data: ReminderEmailData): string {
|
||||
/**
|
||||
* Get the number of days after due date for each reminder level
|
||||
*/
|
||||
export function getReminderDaysConfig(): Record<1 | 2 | 3, number> {
|
||||
return {
|
||||
export type ReminderDaysConfig = Record<1 | 2 | 3, number>
|
||||
|
||||
type ReminderDaysSettings = Partial<
|
||||
Pick<
|
||||
CompanySettings,
|
||||
'reminder_days_level_1' | 'reminder_days_level_2' | 'reminder_days_level_3'
|
||||
>
|
||||
>
|
||||
|
||||
export function getReminderDaysConfig(
|
||||
settings?: ReminderDaysSettings | null,
|
||||
): ReminderDaysConfig {
|
||||
const defaults: ReminderDaysConfig = {
|
||||
1: REMINDER_CONFIG[1].daysAfterDue,
|
||||
2: REMINDER_CONFIG[2].daysAfterDue,
|
||||
3: REMINDER_CONFIG[3].daysAfterDue
|
||||
3: REMINDER_CONFIG[3].daysAfterDue,
|
||||
}
|
||||
|
||||
const configured: ReminderDaysConfig = {
|
||||
1: settings?.reminder_days_level_1 ?? defaults[1],
|
||||
2: settings?.reminder_days_level_2 ?? defaults[2],
|
||||
3: settings?.reminder_days_level_3 ?? defaults[3],
|
||||
}
|
||||
|
||||
const valid =
|
||||
Object.values(configured).every((days) => Number.isInteger(days) && days >= 1 && days <= 365)
|
||||
&& configured[1] < configured[2]
|
||||
&& configured[2] < configured[3]
|
||||
|
||||
return valid ? configured : defaults
|
||||
}
|
||||
|
||||
@@ -428,6 +428,11 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Fakturan är inte i ett obetalt läge och kan inte matchas.',
|
||||
message_en: 'Invoice is not in an unpaid state.',
|
||||
},
|
||||
MATCH_INVOICE_CREDIT_NOTE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Kreditfakturor kan inte registreras som betalda.',
|
||||
message_en: 'Credit notes cannot be recorded as paid.',
|
||||
},
|
||||
MATCH_INVOICE_NOT_INVOICE_TYPE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.',
|
||||
@@ -523,6 +528,11 @@ const LINK_TX_JE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Fakturan är inte i ett obetalt läge och kan inte kopplas.',
|
||||
message_en: 'Invoice is not in an unpaid state.',
|
||||
},
|
||||
LINK_TX_INVOICE_CREDIT_NOTE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Kreditfakturor kan inte registreras som betalda.',
|
||||
message_en: 'Credit notes cannot be recorded as paid.',
|
||||
},
|
||||
LINK_TX_INVOICE_RACE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Fakturan ändrades samtidigt. Försök igen.',
|
||||
@@ -754,6 +764,48 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Endast skickade, betalda eller förfallna fakturor kan krediteras.',
|
||||
message_en: 'Only sent, paid, or overdue invoices can be credited.',
|
||||
},
|
||||
INVOICE_CREDIT_ISSUE_INCOMPLETE: {
|
||||
httpStatus: 500,
|
||||
message_sv:
|
||||
'Kreditfakturan kunde inte utfärdas färdigt. Ingen e-post skickades. Försök igen.',
|
||||
message_en:
|
||||
'The credit note could not be issued completely. No email was sent. Please try again.',
|
||||
},
|
||||
INVOICE_CREDIT_REPAIR_REQUIRED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Kreditfakturans verifikat skapades, men utfärdandet måste slutföras. Försök igen eller kontakta support.',
|
||||
message_en: 'The credit-note voucher was created, but issuance must be completed. Retry or contact support.',
|
||||
},
|
||||
INVOICE_CREDIT_ALREADY_ISSUED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Kreditfakturan har redan utfärdats.',
|
||||
message_en: 'The credit note has already been issued.',
|
||||
},
|
||||
INVOICE_MARK_SENT_INVALID_STATUS: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Fakturan kan inte markeras som skickad i nuvarande status.',
|
||||
message_en: 'The invoice cannot be marked as sent in its current status.',
|
||||
},
|
||||
INVOICE_MARK_SENT_STATUS_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturans status kunde inte uppdateras.',
|
||||
message_en: 'The invoice status could not be updated.',
|
||||
},
|
||||
INVOICE_MARK_SENT_RACE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Fakturan ändrades av en annan begäran. Ladda om och försök igen.',
|
||||
message_en: 'The invoice was changed by another request. Reload and retry.',
|
||||
},
|
||||
INVOICE_MARK_SENT_BOOK_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturan kunde inte bokföras och ligger kvar som utkast.',
|
||||
message_en: 'The invoice could not be posted and remains a draft.',
|
||||
},
|
||||
INVOICE_MARK_SENT_REPAIR_REQUIRED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Verifikatet skapades, men kopplingen till fakturan måste återställas. Kontakta support.',
|
||||
message_en: 'The voucher was created, but its invoice link must be repaired. Contact support.',
|
||||
},
|
||||
INVOICE_SEND_EMAIL_NOT_CONFIGURED: {
|
||||
httpStatus: 503,
|
||||
message_sv:
|
||||
@@ -1639,6 +1691,26 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Artikeln kunde inte uppdateras.',
|
||||
message_en: 'Failed to update article.',
|
||||
},
|
||||
INVOICE_DELETE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturan kunde inte tas bort eller makuleras.',
|
||||
message_en: 'The invoice could not be deleted or cancelled.',
|
||||
},
|
||||
CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Personnummer kan endast sparas för privatkunder.',
|
||||
message_en: 'Personal numbers can only be stored for individual customers.',
|
||||
},
|
||||
ARTICLE_DELETE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Artikeln kunde inte tas bort.',
|
||||
message_en: 'Failed to delete article.',
|
||||
},
|
||||
ARTICLE_IN_USE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Artikeln har använts på en faktura och kan därför inte tas bort.',
|
||||
message_en: 'The article has been used on an invoice and cannot be deleted.',
|
||||
},
|
||||
ARTICLE_REVENUE_ACCOUNT_INVALID: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Försäljningskontot finns inte eller är inte ett aktivt intäktskonto (klass 3).',
|
||||
|
||||
@@ -26,6 +26,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
|
||||
'period.locked',
|
||||
'period.year_closed',
|
||||
'customer.created',
|
||||
'article.deleted',
|
||||
'supplier.created',
|
||||
'receipt.matched',
|
||||
'receipt.confirmed',
|
||||
@@ -98,6 +99,9 @@ function extractEntityId(payload: Record<string, unknown>): string | null {
|
||||
if (typeof payload.invoiceId === 'string') {
|
||||
return payload.invoiceId
|
||||
}
|
||||
if (typeof payload.articleId === 'string') {
|
||||
return payload.articleId
|
||||
}
|
||||
|
||||
// For journal_entry.corrected: use the corrected entry's ID
|
||||
if ('corrected' in payload) {
|
||||
|
||||
+2
-1
@@ -87,6 +87,7 @@ export type CoreEvent =
|
||||
// Articles (artikelregister)
|
||||
| { type: 'article.created'; payload: { article: Article; userId: string; companyId: string } }
|
||||
| { type: 'article.updated'; payload: { article: Article; userId: string; companyId: string } }
|
||||
| { type: 'article.deleted'; payload: { articleId: string; userId: string; companyId: string } }
|
||||
// Suppliers
|
||||
| { type: 'supplier.created'; payload: { supplier: Supplier; userId: string; companyId: string } }
|
||||
// Receipts
|
||||
@@ -174,7 +175,7 @@ export type CoreEvent =
|
||||
success: boolean // true iff the tool returned without throwing AND was invoked (not denied)
|
||||
isError: boolean // matches the JSON-RPC tool-result isError flag returned to the client
|
||||
errorCode: string | null // structured error code from tool-result.toToolError when applicable
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorMessage: string | null // human-readable error message (truncated to 500 chars), null on success.
|
||||
// Raw material for clustering real agent failures into curated gotchas:
|
||||
// errorCode alone can't distinguish "period locked" from "unbalanced".
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"icon": "Mail",
|
||||
"dataPattern": "core",
|
||||
"description": "Skicka fakturor och påminnelser via e-post",
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
|
||||
"readsCoreTables": [
|
||||
"invoices",
|
||||
"customers",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item'
|
||||
import type { InvoiceItem } from '@/types'
|
||||
|
||||
function item(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'invoice-1',
|
||||
sort_order: 0,
|
||||
description: 'Arbete',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 2000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 500,
|
||||
created_at: '2026-07-14T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildCreditNoteItem', () => {
|
||||
it('negates amounts and preserves ROT/RUT, account, accrual, and dimension metadata', () => {
|
||||
const result = buildCreditNoteItem('credit-1', item({
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: 600,
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Test 1:2',
|
||||
revenue_account: '3041',
|
||||
accrual_period_start: '2026-07-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
dimensions: { '6': 'P001' },
|
||||
}))
|
||||
|
||||
expect(result).toMatchObject({
|
||||
invoice_id: 'credit-1',
|
||||
quantity: -2,
|
||||
line_total: -2000,
|
||||
vat_amount: -500,
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: -600,
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Test 1:2',
|
||||
revenue_account: '3041',
|
||||
accrual_period_start: '2026-07-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { makeInvoice } from '@/tests/helpers'
|
||||
import { buildInvoiceCopyInitial, canCopyInvoice } from '@/lib/invoices/copy-invoice'
|
||||
import type { InvoiceItem } from '@/types'
|
||||
|
||||
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'invoice-1',
|
||||
sort_order: 0,
|
||||
line_type: 'product',
|
||||
description: 'Consulting',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 2000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 500,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('canCopyInvoice', () => {
|
||||
it.each(['sent', 'paid', 'partially_paid', 'overdue', 'credited'] as const)(
|
||||
'allows an issued ordinary invoice with status %s',
|
||||
(status) => {
|
||||
expect(canCopyInvoice(makeInvoice({ status }))).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects drafts, credit notes, other document types, and self-billing invoices', () => {
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'draft' }))).toBe(false)
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'sent', credited_invoice_id: 'invoice-original' }))).toBe(false)
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'sent', document_type: 'proforma' }))).toBe(false)
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'sent', is_self_billed: true }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInvoiceCopyInitial', () => {
|
||||
it('copies reusable content and clears lifecycle-specific fields', () => {
|
||||
const source = makeInvoice({
|
||||
id: 'invoice-original',
|
||||
invoice_number: 'F-2026007',
|
||||
status: 'paid',
|
||||
invoice_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
delivery_date: '2025-12-20',
|
||||
your_reference: 'Old customer contact',
|
||||
our_reference: 'Seller contact',
|
||||
notes: 'Reusable terms',
|
||||
payment_link_url: 'https://example.com/old-payment',
|
||||
journal_entry_id: 'journal-1',
|
||||
ore_rounding: true,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
const first = makeItem({
|
||||
sort_order: 1,
|
||||
article_id: 'article-1',
|
||||
revenue_account: '3041',
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Old property',
|
||||
apartment_number: '1201',
|
||||
brf_org_number: '5560000000',
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-06-30',
|
||||
accrual_balance_account: '2970',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
const second = makeItem({ id: 'item-2', sort_order: 0, description: 'First row' })
|
||||
|
||||
const copy = buildInvoiceCopyInitial({ ...source, items: [first, second] })
|
||||
|
||||
expect(copy).toMatchObject({
|
||||
source_invoice_number: 'F-2026007',
|
||||
customer_id: source.customer_id,
|
||||
currency: 'SEK',
|
||||
document_type: 'invoice',
|
||||
our_reference: 'Seller contact',
|
||||
notes: 'Reusable terms',
|
||||
ore_rounding: true,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
expect(copy.items.map((item) => item.description)).toEqual(['First row', 'Consulting'])
|
||||
expect(copy.items[1]).toMatchObject({
|
||||
article_id: null,
|
||||
revenue_account: '3041',
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
expect(copy).not.toHaveProperty('invoice_date')
|
||||
expect(copy).not.toHaveProperty('due_date')
|
||||
expect(copy).not.toHaveProperty('your_reference')
|
||||
expect(copy).not.toHaveProperty('payment_link_url')
|
||||
expect(copy).not.toHaveProperty('journal_entry_id')
|
||||
expect(copy).not.toHaveProperty('status')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
|
||||
|
||||
async function seedCustomerInvoicePair(): Promise<{
|
||||
originalInvoiceId: string
|
||||
creditNoteId: string
|
||||
}> {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId })
|
||||
|
||||
const customerId = randomUUID()
|
||||
const originalInvoiceId = randomUUID()
|
||||
const creditNoteId = randomUUID()
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
|
||||
VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`,
|
||||
[customerId, userId, companyId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
|
||||
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
|
||||
paid_amount, remaining_amount)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-07-01', '2026-07-31', 'SEK',
|
||||
1000, 0, 1000, 'standard_25', 25, 'sent', 0, 1000)`,
|
||||
[originalInvoiceId, userId, companyId, customerId, `F-${originalInvoiceId.slice(0, 8)}`],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
|
||||
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
|
||||
paid_amount, remaining_amount, credited_invoice_id)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-07-01', '2026-07-31', 'SEK',
|
||||
-1000, 0, -1000, 'standard_25', 25, 'sent', 0, -1000, $6)`,
|
||||
[creditNoteId, userId, companyId, customerId, `KR-${creditNoteId.slice(0, 8)}`, originalInvoiceId],
|
||||
)
|
||||
|
||||
return { originalInvoiceId, creditNoteId }
|
||||
}
|
||||
|
||||
describe('invoices_credit_note_not_paid constraint', () => {
|
||||
it.each(['paid', 'partially_paid'])('rejects credit-note status %s', async (status) => {
|
||||
const { creditNoteId } = await seedCustomerInvoicePair()
|
||||
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.invoices SET status = $1 WHERE id = $2`, [status, creditNoteId]),
|
||||
).rejects.toMatchObject({ code: '23514' })
|
||||
})
|
||||
|
||||
it('allows an ordinary customer invoice to be marked paid', async () => {
|
||||
const { originalInvoiceId } = await seedCustomerInvoicePair()
|
||||
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.invoices SET status = 'paid' WHERE id = $1`, [originalInvoiceId]),
|
||||
).resolves.toMatchObject({ rowCount: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
|
||||
|
||||
describe('getCreditNoteSendMode', () => {
|
||||
it('prefers email when delivery is available and the customer has an address', () => {
|
||||
expect(getCreditNoteSendMode({
|
||||
customerHasEmail: true,
|
||||
isSandbox: false,
|
||||
canEmail: true,
|
||||
})).toBe('email')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ customerHasEmail: false, isSandbox: false, canEmail: true },
|
||||
{ customerHasEmail: true, isSandbox: true, canEmail: true },
|
||||
{ customerHasEmail: true, isSandbox: false, canEmail: false },
|
||||
])('falls back to manual issuance for $customerHasEmail/$isSandbox/$canEmail', (input) => {
|
||||
expect(getCreditNoteSendMode(input)).toBe('manual')
|
||||
})
|
||||
})
|
||||
@@ -184,6 +184,25 @@ describe('findMatchingInvoices', () => {
|
||||
expect(result[0].matchReason).toContain('OCR-referens')
|
||||
})
|
||||
|
||||
it('never suggests a credit note, even when its OCR reference matches', async () => {
|
||||
const tx = makeTransaction({ amount: 12500, reference: 'KR-F-2024001' })
|
||||
mockResult({
|
||||
data: [
|
||||
makeInvoice({
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'sent',
|
||||
total: -12500,
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns immediately on OCR match without further scoring', async () => {
|
||||
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
|
||||
mockResult({
|
||||
|
||||
@@ -29,4 +29,14 @@ describe('isEditableInvoiceDraft', () => {
|
||||
isEditableInvoiceDraft({ status: 'draft', journal_entry_id: null, is_self_billed: true }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks a credit-note draft because it must continue mirroring the original', () => {
|
||||
expect(
|
||||
isEditableInvoiceDraft({
|
||||
status: 'draft',
|
||||
journal_entry_id: null,
|
||||
credited_invoice_id: 'invoice-1',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { CreditNote } from '@/types'
|
||||
|
||||
const mockCreateCreditNoteJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createCreditNoteJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateCreditNoteJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockCancelSchedulesForSource = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/accruals/service', () => ({
|
||||
cancelSchedulesForSource: (...args: unknown[]) =>
|
||||
mockCancelSchedulesForSource(...args),
|
||||
}))
|
||||
|
||||
import {
|
||||
creditNoteNeedsJournalEntry,
|
||||
issueCreditNote,
|
||||
} from '@/lib/invoices/issue-credit-note'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const log: Logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn(),
|
||||
}
|
||||
|
||||
function makeCreditNote(overrides: Partial<CreditNote> = {}) {
|
||||
return {
|
||||
...makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-100',
|
||||
invoice_date: '2026-07-14',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'invoice-1',
|
||||
subtotal: -1000,
|
||||
vat_amount: -250,
|
||||
total: -1250,
|
||||
...overrides,
|
||||
}),
|
||||
customer: { name: 'Testkund' },
|
||||
} as CreditNote & { customer: { name: string } }
|
||||
}
|
||||
|
||||
describe('issueCreditNote', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockCancelSchedulesForSource.mockResolvedValue({
|
||||
cancelledSchedules: 1,
|
||||
reversedEntries: 0,
|
||||
failedReversals: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('books an accrual credit note, links it, cancels accruals, and marks the original', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { voucher_series: 'A', voucher_number: 42 }, error: null })
|
||||
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'journal-1' })
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
enqueue({ data: [{ id: 'invoice-1' }], error: null })
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: {
|
||||
id: 'invoice-1',
|
||||
invoice_number: 'F-100',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-journal-1',
|
||||
},
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'accrual',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
complete: true,
|
||||
journalEntryId: 'journal-1',
|
||||
journalEntryRequired: true,
|
||||
repairRequired: false,
|
||||
failures: [],
|
||||
})
|
||||
expect(mockCreateCreditNoteJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'credit-1', status: 'sent' }),
|
||||
'enskild_firma',
|
||||
'Testkund',
|
||||
'A-42',
|
||||
)
|
||||
expect(mockCancelSchedulesForSource).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
{ invoiceId: 'invoice-1' },
|
||||
{ reversalDate: '2026-07-14' },
|
||||
)
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'credit_note.created' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('marks and emits a cash-method credit note without creating a journal entry', async () => {
|
||||
enqueue({ data: [{ id: 'invoice-1' }], error: null })
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'cash',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
complete: true,
|
||||
journalEntryId: null,
|
||||
journalEntryRequired: false,
|
||||
repairRequired: false,
|
||||
failures: [],
|
||||
})
|
||||
expect(mockCreateCreditNoteJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not cancel accrual schedules when the credit journal entry fails', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
mockCreateCreditNoteJournalEntry.mockRejectedValue(new Error('Perioden är låst'))
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'accrual',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.journalEntryId).toBeNull()
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.failures).toEqual([
|
||||
{ step: 'journal_entry', reason: 'Perioden är låst' },
|
||||
])
|
||||
expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books a paid cash-method original before marking it credited', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'journal-cash' })
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
enqueue({ data: [{ id: 'invoice-1' }], error: null })
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: {
|
||||
id: 'invoice-1',
|
||||
invoice_number: 'F-100',
|
||||
status: 'paid',
|
||||
paid_at: '2026-07-01T00:00:00Z',
|
||||
},
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'cash',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.complete).toBe(true)
|
||||
expect(result.journalEntryId).toBe('journal-cash')
|
||||
expect(mockCreateCreditNoteJournalEntry).toHaveBeenCalledOnce()
|
||||
expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects when cash-method credit notes need a reversal voucher', () => {
|
||||
const original = { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' }
|
||||
expect(creditNoteNeedsJournalEntry('cash', original)).toBe(false)
|
||||
expect(creditNoteNeedsJournalEntry('cash', { ...original, status: 'paid' })).toBe(true)
|
||||
expect(creditNoteNeedsJournalEntry('cash', { ...original, paid_amount: 100 })).toBe(true)
|
||||
expect(creditNoteNeedsJournalEntry('accrual', original)).toBe(true)
|
||||
})
|
||||
|
||||
it('reuses the posted voucher that wins a concurrent create race', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
mockCreateCreditNoteJournalEntry.mockRejectedValue(new Error('duplicate source'))
|
||||
enqueue({ data: { id: 'journal-winner' }, error: null })
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
enqueue({ data: [{ id: 'invoice-1' }], error: null })
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'accrual',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.complete).toBe(true)
|
||||
expect(result.journalEntryId).toBe('journal-winner')
|
||||
expect(result.failures).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
|
||||
|
||||
async function seedCompanySettings(): Promise<string> {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId })
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id)
|
||||
VALUES ($1, $2)`,
|
||||
[userId, companyId],
|
||||
)
|
||||
return companyId
|
||||
}
|
||||
|
||||
describe('company_settings reminder day constraints', () => {
|
||||
it('uses the legacy 15, 30, and 45 day defaults', async () => {
|
||||
const companyId = await seedCompanySettings()
|
||||
|
||||
const result = await getPool().query(
|
||||
`SELECT reminder_days_level_1, reminder_days_level_2, reminder_days_level_3
|
||||
FROM public.company_settings
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
expect(result.rows[0]).toMatchObject({
|
||||
reminder_days_level_1: 15,
|
||||
reminder_days_level_2: 30,
|
||||
reminder_days_level_3: 45,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a strictly increasing custom schedule', async () => {
|
||||
const companyId = await seedCompanySettings()
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.company_settings
|
||||
SET reminder_days_level_1 = 7,
|
||||
reminder_days_level_2 = 21,
|
||||
reminder_days_level_3 = 35
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
),
|
||||
).resolves.toMatchObject({ rowCount: 1 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[30, 20, 45],
|
||||
[15, 15, 45],
|
||||
[0, 30, 45],
|
||||
[15, 30, 366],
|
||||
])('rejects invalid schedule %s, %s, %s', async (level1, level2, level3) => {
|
||||
const companyId = await seedCompanySettings()
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.company_settings
|
||||
SET reminder_days_level_1 = $1,
|
||||
reminder_days_level_2 = $2,
|
||||
reminder_days_level_3 = $3
|
||||
WHERE company_id = $4`,
|
||||
[level1, level2, level3, companyId],
|
||||
),
|
||||
).rejects.toMatchObject({ code: '23514' })
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
determineReminderLevel,
|
||||
calculateDaysOverdue,
|
||||
} from '../reminder-processor'
|
||||
import { getReminderDaysConfig } from '@/lib/email/reminder-templates'
|
||||
|
||||
describe('determineReminderLevel', () => {
|
||||
it('returns null below the level-1 threshold', () => {
|
||||
@@ -60,6 +61,37 @@ describe('determineReminderLevel', () => {
|
||||
it('returns null when all levels have been sent', () => {
|
||||
expect(determineReminderLevel(60, [1, 2, 3])).toBeNull()
|
||||
})
|
||||
|
||||
it('uses a company-specific schedule', () => {
|
||||
const config = { 1: 7, 2: 21, 3: 35 } as const
|
||||
|
||||
expect(determineReminderLevel(6, [], config)).toBeNull()
|
||||
expect(determineReminderLevel(7, [], config)).toBe(1)
|
||||
expect(determineReminderLevel(21, [1], config)).toBe(2)
|
||||
expect(determineReminderLevel(35, [1, 2], config)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReminderDaysConfig', () => {
|
||||
it('returns the existing defaults when no company settings are supplied', () => {
|
||||
expect(getReminderDaysConfig()).toEqual({ 1: 15, 2: 30, 3: 45 })
|
||||
})
|
||||
|
||||
it('returns configured company thresholds', () => {
|
||||
expect(getReminderDaysConfig({
|
||||
reminder_days_level_1: 5,
|
||||
reminder_days_level_2: 10,
|
||||
reminder_days_level_3: 20,
|
||||
})).toEqual({ 1: 5, 2: 10, 3: 20 })
|
||||
})
|
||||
|
||||
it('falls back to defaults for an invalid stored schedule', () => {
|
||||
expect(getReminderDaysConfig({
|
||||
reminder_days_level_1: 30,
|
||||
reminder_days_level_2: 20,
|
||||
reminder_days_level_3: 45,
|
||||
})).toEqual({ 1: 15, 2: 30, 3: 45 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateDaysOverdue', () => {
|
||||
|
||||
@@ -49,6 +49,28 @@ describe('settleInvoicePayment', () => {
|
||||
vi.mocked(createInvoiceCashEntry).mockResolvedValue({ id: 'je-2' } as never)
|
||||
})
|
||||
|
||||
it('rejects credit notes before creating a journal entry or updating state', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{
|
||||
...BASE_PARAMS,
|
||||
invoice: payableInvoice({ credited_invoice_id: 'original-invoice-1' }),
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'INVOICE_PAID_NOT_PAYABLE',
|
||||
details: { reason: 'credit_note' },
|
||||
})
|
||||
expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(createInvoiceCashEntry)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books via the payment entry and forwards the settlement account', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InvoiceItem } from '@/types'
|
||||
|
||||
export function buildCreditNoteItem(invoiceId: string, item: InvoiceItem) {
|
||||
return {
|
||||
invoice_id: invoiceId,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
description: item.description,
|
||||
quantity: -Math.abs(item.quantity),
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_rate: item.vat_rate ?? 0,
|
||||
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
||||
revenue_account: item.revenue_account ?? null,
|
||||
article_id: item.article_id ?? null,
|
||||
deduction_type: item.deduction_type ?? null,
|
||||
deduction_amount: item.deduction_amount
|
||||
? -Math.abs(item.deduction_amount)
|
||||
: 0,
|
||||
labor_hours: item.labor_hours ?? null,
|
||||
work_type: item.work_type ?? null,
|
||||
housing_designation: item.housing_designation ?? null,
|
||||
apartment_number: item.apartment_number ?? null,
|
||||
brf_org_number: item.brf_org_number ?? null,
|
||||
accrual_period_start: item.accrual_period_start ?? null,
|
||||
accrual_period_end: item.accrual_period_end ?? null,
|
||||
accrual_balance_account: item.accrual_balance_account ?? null,
|
||||
dimensions: item.dimensions ?? {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
Currency,
|
||||
Invoice,
|
||||
InvoiceDocumentType,
|
||||
InvoiceItem,
|
||||
InvoiceStatus,
|
||||
} from '@/types'
|
||||
|
||||
const COPYABLE_STATUSES: ReadonlySet<InvoiceStatus> = new Set([
|
||||
'sent',
|
||||
'paid',
|
||||
'partially_paid',
|
||||
'overdue',
|
||||
'credited',
|
||||
])
|
||||
|
||||
export type InvoiceCopySource = Invoice & { items: InvoiceItem[] }
|
||||
|
||||
export interface InvoiceCopyItem {
|
||||
line_type: 'product' | 'text'
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate: number
|
||||
article_id: null
|
||||
revenue_account: string | null
|
||||
deduction_type: 'rot' | 'rut' | null
|
||||
labor_hours: number | null
|
||||
work_type: string | null
|
||||
housing_designation: null
|
||||
apartment_number: null
|
||||
brf_org_number: null
|
||||
accrual_period_start: null
|
||||
accrual_period_end: null
|
||||
accrual_balance_account: null
|
||||
dimensions: Record<string, string> | null
|
||||
}
|
||||
|
||||
export interface InvoiceCopyInitial {
|
||||
source_invoice_number: string
|
||||
customer_id: string
|
||||
currency: Currency
|
||||
document_type: InvoiceDocumentType
|
||||
our_reference: string
|
||||
notes: string
|
||||
ore_rounding: boolean | null
|
||||
default_dimensions: Record<string, string>
|
||||
items: InvoiceCopyItem[]
|
||||
}
|
||||
|
||||
export function canCopyInvoice(
|
||||
invoice: Pick<Invoice, 'status' | 'document_type' | 'credited_invoice_id' | 'is_self_billed'>,
|
||||
): boolean {
|
||||
return (
|
||||
invoice.document_type === 'invoice' &&
|
||||
!invoice.credited_invoice_id &&
|
||||
!invoice.is_self_billed &&
|
||||
COPYABLE_STATUSES.has(invoice.status)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a safe starting point for a new invoice draft.
|
||||
*
|
||||
* The copied data is limited to reusable commercial content. Identity,
|
||||
* lifecycle, payment, bookkeeping, date, accrual, and recipient-specific
|
||||
* ROT/RUT fields are deliberately absent or cleared.
|
||||
*/
|
||||
export function buildInvoiceCopyInitial(source: InvoiceCopySource): InvoiceCopyInitial {
|
||||
return {
|
||||
source_invoice_number: source.invoice_number ?? '',
|
||||
customer_id: source.customer_id,
|
||||
currency: source.currency,
|
||||
document_type: 'invoice',
|
||||
our_reference: source.our_reference ?? '',
|
||||
notes: source.notes ?? '',
|
||||
ore_rounding: source.ore_rounding,
|
||||
default_dimensions: source.default_dimensions ?? {},
|
||||
items: [...source.items]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((item) => ({
|
||||
line_type: item.line_type ?? 'product',
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
vat_rate: item.vat_rate ?? 25,
|
||||
// A copied line keeps the frozen description and price, but is not
|
||||
// linked to a possibly changed or archived article preset.
|
||||
article_id: null,
|
||||
revenue_account: item.revenue_account ?? null,
|
||||
deduction_type: item.deduction_type ?? null,
|
||||
labor_hours: item.labor_hours ?? null,
|
||||
work_type: item.work_type ?? null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
// Accrual dates belong to the original accounting period.
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
dimensions: item.dimensions && Object.keys(item.dimensions).length > 0
|
||||
? item.dimensions
|
||||
: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getCreditNoteSendMode(input: {
|
||||
customerHasEmail: boolean
|
||||
isSandbox: boolean
|
||||
canEmail: boolean
|
||||
}): 'email' | 'manual' {
|
||||
return input.customerHasEmail && !input.isSandbox && input.canEmail
|
||||
? 'email'
|
||||
: 'manual'
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { Invoice, Transaction, Customer } from '@/types'
|
||||
|
||||
export interface InvoiceMatch {
|
||||
@@ -130,17 +131,19 @@ export async function findMatchingInvoices(
|
||||
}
|
||||
|
||||
// Query unpaid invoices (sent or overdue) with customer info
|
||||
const { data: invoices, error } = await supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*)
|
||||
`)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
if (error || !invoices) {
|
||||
let invoices: Array<Invoice & { customer?: { name?: string | null } | null }>
|
||||
try {
|
||||
invoices = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
|
||||
.eq('company_id', companyId)
|
||||
.is('credited_invoice_id', null)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
} catch {
|
||||
// Failed to fetch invoices: return empty matches
|
||||
return []
|
||||
}
|
||||
@@ -149,22 +152,36 @@ export async function findMatchingInvoices(
|
||||
// attached but whose status leaked (still 'sent'/'overdue'). Partially-paid
|
||||
// invoices can legitimately take more payments, so they pass through.
|
||||
// Without this, a status leak would double-book the receipt.
|
||||
const fullCandidateIds = invoices
|
||||
const payableInvoices = invoices.filter(
|
||||
(invoice) => {
|
||||
const creditNotes = (invoice as Invoice & {
|
||||
credit_notes?: Array<{ status: string; creation_complete?: boolean }>
|
||||
}).credit_notes ?? []
|
||||
return !invoice.credited_invoice_id && !creditNotes.some(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
},
|
||||
)
|
||||
const fullCandidateIds = payableInvoices
|
||||
.filter((inv) => inv.status === 'sent' || inv.status === 'overdue')
|
||||
.map((inv) => inv.id as string)
|
||||
const paidIds = new Set<string>()
|
||||
if (fullCandidateIds.length > 0) {
|
||||
const { data: paymentRows } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('invoice_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('invoice_id', fullCandidateIds)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
for (const row of paymentRows ?? []) {
|
||||
const paymentRows = await fetchAllRows<{ id: string; invoice_id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoice_payments')
|
||||
.select('id, invoice_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('invoice_id', fullCandidateIds)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
for (const row of paymentRows) {
|
||||
paidIds.add((row as { invoice_id: string }).invoice_id)
|
||||
}
|
||||
}
|
||||
const filteredInvoices = invoices.filter((inv) => !paidIds.has(inv.id as string))
|
||||
const filteredInvoices = payableInvoices.filter((inv) => !paidIds.has(inv.id as string))
|
||||
if (filteredInvoices.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* entry is created when the invoice is sent (mark-sent / send) or, for
|
||||
* kontantmetoden, at payment; once one exists, BFL immutability applies and the
|
||||
* invoice must be corrected with a credit note instead. A self-billed invoice we
|
||||
* received is the counterparty's document: never editable here.
|
||||
* received is the counterparty's document: never editable here. Credit-note
|
||||
* drafts mirror an issued invoice and must not be changed into a different
|
||||
* correction after creation.
|
||||
*
|
||||
* This is the single source of truth for that predicate. The PATCH route
|
||||
* (app/api/invoices/[id]/route.ts) enforces it server-side; the detail and edit
|
||||
@@ -15,6 +17,12 @@ export function isEditableInvoiceDraft(invoice: {
|
||||
status: string
|
||||
journal_entry_id?: string | null
|
||||
is_self_billed?: boolean | null
|
||||
credited_invoice_id?: string | null
|
||||
}): boolean {
|
||||
return invoice.status === 'draft' && !invoice.journal_entry_id && !invoice.is_self_billed
|
||||
return (
|
||||
invoice.status === 'draft' &&
|
||||
!invoice.journal_entry_id &&
|
||||
!invoice.is_self_billed &&
|
||||
!invoice.credited_invoice_id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { AccountingMethod, CreditNote, EntityType } from '@/types'
|
||||
|
||||
export interface CreditNoteOriginalInvoice {
|
||||
id: string
|
||||
invoice_number: string | null
|
||||
status: string
|
||||
journal_entry_id?: string | null
|
||||
paid_at?: string | null
|
||||
paid_amount?: number | null
|
||||
total?: number | null
|
||||
}
|
||||
|
||||
export interface CreditNoteIssueFailure {
|
||||
step: 'journal_entry' | 'journal_link' | 'accrual_schedules' | 'original_status'
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface IssueCreditNoteInput {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
userId: string
|
||||
creditNote: CreditNote & { customer?: { name?: string | null } | null }
|
||||
originalInvoice: CreditNoteOriginalInvoice
|
||||
entityType: EntityType
|
||||
accountingMethod: AccountingMethod
|
||||
log: Logger
|
||||
}
|
||||
|
||||
export interface IssueCreditNoteResult {
|
||||
complete: boolean
|
||||
journalEntryId: string | null
|
||||
journalEntryRequired: boolean
|
||||
repairRequired: boolean
|
||||
failures: CreditNoteIssueFailure[]
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Okänt fel'
|
||||
}
|
||||
|
||||
/**
|
||||
* Faktureringsmetoden always books the credit on issue. Kontantmetoden only
|
||||
* books it when the original sale has already reached the ledger, for example
|
||||
* through a completed payment or a year-end receivable entry.
|
||||
*/
|
||||
export function creditNoteNeedsJournalEntry(
|
||||
accountingMethod: AccountingMethod,
|
||||
originalInvoice: CreditNoteOriginalInvoice,
|
||||
): boolean {
|
||||
return (
|
||||
accountingMethod === 'accrual' ||
|
||||
!!originalInvoice.journal_entry_id ||
|
||||
originalInvoice.status === 'paid' ||
|
||||
!!originalInvoice.paid_at ||
|
||||
Math.abs(originalInvoice.paid_amount ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
async function getOriginalVoucherRef(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
journalEntryId: string | null | undefined,
|
||||
log: Logger,
|
||||
): Promise<string | undefined> {
|
||||
if (!journalEntryId) return undefined
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
log.warn('failed to load original voucher reference for credit note', error)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!data?.voucher_series || data.voucher_number == null) return undefined
|
||||
return `${data.voucher_series}-${data.voucher_number}`
|
||||
}
|
||||
|
||||
async function findExistingCreditJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
creditNote: CreditNote,
|
||||
): Promise<string | null> {
|
||||
if (creditNote.journal_entry_id) return creditNote.journal_entry_id
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_type', 'credit_note')
|
||||
.eq('source_id', creditNote.id)
|
||||
.eq('status', 'posted')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
return data?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the accounting side of credit-note issuance after the caller has
|
||||
* won the draft-to-sent compare-and-set. Every step is idempotent so a sent
|
||||
* credit note with incomplete bookkeeping can be repaired without creating a
|
||||
* second immutable voucher.
|
||||
*/
|
||||
export async function issueCreditNote(input: IssueCreditNoteInput): Promise<IssueCreditNoteResult> {
|
||||
const {
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
creditNote,
|
||||
originalInvoice,
|
||||
entityType,
|
||||
accountingMethod,
|
||||
log,
|
||||
} = input
|
||||
const failures: CreditNoteIssueFailure[] = []
|
||||
const journalEntryRequired = creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
|
||||
let journalEntryId: string | null = null
|
||||
const issuedCreditNote = { ...creditNote, status: 'sent' as const }
|
||||
|
||||
if (journalEntryRequired) {
|
||||
try {
|
||||
journalEntryId = await findExistingCreditJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
issuedCreditNote,
|
||||
)
|
||||
|
||||
if (!journalEntryId) {
|
||||
const originalVoucherRef = await getOriginalVoucherRef(
|
||||
supabase,
|
||||
companyId,
|
||||
originalInvoice.journal_entry_id,
|
||||
log,
|
||||
)
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
issuedCreditNote,
|
||||
entityType,
|
||||
creditNote.customer?.name ?? undefined,
|
||||
originalVoucherRef,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (error) {
|
||||
// A concurrent issuer may have won the unique posted-source guard after
|
||||
// our initial lookup. Re-read and reuse that immutable voucher.
|
||||
try {
|
||||
journalEntryId = await findExistingCreditJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
issuedCreditNote,
|
||||
)
|
||||
} catch (recoveryError) {
|
||||
log.error('failed to recover credit note journal entry after create conflict', recoveryError, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
}
|
||||
if (!journalEntryId) {
|
||||
log.error('failed to create or recover credit note journal entry on issue', error, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
failures.push({ step: 'journal_entry', reason: errorMessage(error) })
|
||||
}
|
||||
}
|
||||
|
||||
if (!journalEntryId) {
|
||||
if (failures.length === 0) {
|
||||
failures.push({
|
||||
step: 'journal_entry',
|
||||
reason: 'Ingen öppen bokföringsperiod hittades för kreditfakturans datum.',
|
||||
})
|
||||
}
|
||||
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: false, failures }
|
||||
}
|
||||
|
||||
if (creditNote.journal_entry_id !== journalEntryId) {
|
||||
const { data: linkedRows, error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', creditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'sent')
|
||||
.select('id')
|
||||
|
||||
if (linkError || !linkedRows || linkedRows.length === 0) {
|
||||
log.error('failed to link credit note to journal entry', linkError ?? undefined, {
|
||||
creditNoteId: creditNote.id,
|
||||
journalEntryId,
|
||||
})
|
||||
failures.push({
|
||||
step: 'journal_link',
|
||||
reason: linkError?.message ?? 'Kreditfakturan kunde inte kopplas till verifikatet.',
|
||||
})
|
||||
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: true, failures }
|
||||
}
|
||||
}
|
||||
|
||||
if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const cancelResult = await cancelSchedulesForSource(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
{ invoiceId: originalInvoice.id },
|
||||
{ reversalDate: creditNote.invoice_date },
|
||||
)
|
||||
if (cancelResult.failedReversals > 0) {
|
||||
failures.push({
|
||||
step: 'accrual_schedules',
|
||||
reason:
|
||||
'En eller flera periodiseringsverifikat kunde inte vändas. ' +
|
||||
'Kontrollera Bokföring > Periodiseringar.',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('failed to cancel accrual schedules for credited invoice', error)
|
||||
failures.push({ step: 'accrual_schedules', reason: errorMessage(error) })
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: true, failures }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let originalStatusChanged = false
|
||||
if (originalInvoice.status !== 'credited') {
|
||||
const { data: updatedOriginal, error: originalStatusError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'credited' })
|
||||
.eq('id', originalInvoice.id)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.select('id')
|
||||
|
||||
if (originalStatusError || !updatedOriginal || updatedOriginal.length === 0) {
|
||||
log.error('failed to mark original invoice as credited', originalStatusError ?? undefined, {
|
||||
originalInvoiceId: originalInvoice.id,
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
failures.push({
|
||||
step: 'original_status',
|
||||
reason: originalStatusError?.message ?? 'Originalfakturan kunde inte markeras som krediterad.',
|
||||
})
|
||||
return {
|
||||
complete: false,
|
||||
journalEntryId,
|
||||
journalEntryRequired,
|
||||
repairRequired: journalEntryRequired && !!journalEntryId,
|
||||
failures,
|
||||
}
|
||||
}
|
||||
originalStatusChanged = true
|
||||
}
|
||||
|
||||
if (originalStatusChanged) {
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'credit_note.created',
|
||||
payload: { creditNote: issuedCreditNote, companyId, userId },
|
||||
})
|
||||
} catch (error) {
|
||||
log.warn('credit_note.created emit failed after completed issuance', error)
|
||||
}
|
||||
}
|
||||
|
||||
return { complete: true, journalEntryId, journalEntryRequired, repairRequired: false, failures }
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
generateReminderEmailHtml,
|
||||
generateReminderEmailText,
|
||||
generateReminderEmailSubject,
|
||||
getReminderDaysConfig
|
||||
getReminderDaysConfig,
|
||||
type ReminderDaysConfig,
|
||||
} from '@/lib/email/reminder-templates'
|
||||
import { calculateLatePaymentInterest } from '@/lib/invoices/late-payment-interest'
|
||||
import { createReminderFeeEntry } from '@/lib/bookkeeping/reminder-fee-entries'
|
||||
@@ -49,21 +50,19 @@ export interface ProcessRemindersResult {
|
||||
*/
|
||||
export function determineReminderLevel(
|
||||
daysOverdue: number,
|
||||
existingLevels: number[]
|
||||
existingLevels: number[],
|
||||
config: ReminderDaysConfig = getReminderDaysConfig(),
|
||||
): 1 | 2 | 3 | null {
|
||||
const config = getReminderDaysConfig()
|
||||
|
||||
// Check level 3 (45 days)
|
||||
// Check the highest eligible level first, preserving the existing behavior
|
||||
// when a previous cron run was missed.
|
||||
if (daysOverdue >= config[3] && !existingLevels.includes(3)) {
|
||||
return 3
|
||||
}
|
||||
|
||||
// Check level 2 (30 days)
|
||||
if (daysOverdue >= config[2] && !existingLevels.includes(2)) {
|
||||
return 2
|
||||
}
|
||||
|
||||
// Check level 1 (15 days)
|
||||
if (daysOverdue >= config[1] && !existingLevels.includes(1)) {
|
||||
return 1
|
||||
}
|
||||
@@ -146,12 +145,11 @@ export async function sendReminder(
|
||||
export async function processOverdueReminders(): Promise<ProcessRemindersResult> {
|
||||
const supabase = createServiceClient()
|
||||
const results: ReminderResult[] = []
|
||||
const config = getReminderDaysConfig()
|
||||
|
||||
// Find all sent invoices that are past due date (at least 15 days overdue)
|
||||
const minOverdueDays = config[1]
|
||||
// Company schedules can start as early as one day overdue. Fetch that
|
||||
// bounded candidate set, then apply each company's thresholds below.
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - minOverdueDays)
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 1)
|
||||
|
||||
// Positive allowlist: inherently excludes 'paid', 'partially_paid', 'cancelled', 'credited'.
|
||||
// Including 'overdue' ensures level-2 / level-3 reminders re-fire after the first reminder
|
||||
@@ -160,7 +158,8 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*)
|
||||
customer:customers(*),
|
||||
credit_notes:invoices!credited_invoice_id(id, status, creation_complete)
|
||||
`)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.is('credited_invoice_id', null)
|
||||
@@ -181,6 +180,17 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
|
||||
// Process each invoice
|
||||
for (const invoice of overdueInvoices) {
|
||||
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> }).credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (activeCreditNotes.length > 0) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: active credit note exists`)
|
||||
continue
|
||||
}
|
||||
|
||||
const customer = invoice.customer as Customer
|
||||
|
||||
// Skip if customer has no email
|
||||
@@ -209,13 +219,6 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
|
||||
const existingLevels = existingReminders?.map(r => r.reminder_level) || []
|
||||
const daysOverdue = calculateDaysOverdue(invoice.due_date)
|
||||
const reminderLevel = determineReminderLevel(daysOverdue, existingLevels)
|
||||
|
||||
// Skip if no reminder needed
|
||||
if (!reminderLevel) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: no reminder needed (${daysOverdue} days overdue, existing levels: ${existingLevels.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get company settings for this user
|
||||
const { data: company, error: companyError } = await supabase
|
||||
@@ -226,14 +229,17 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
|
||||
if (companyError || !company) {
|
||||
log.error(`Skipping invoice ${invoice.invoice_number}: company settings not found`)
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerEmail: customer.email,
|
||||
reminderLevel,
|
||||
success: false,
|
||||
error: 'Company settings not found'
|
||||
})
|
||||
const fallbackLevel = determineReminderLevel(daysOverdue, existingLevels)
|
||||
if (fallbackLevel) {
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerEmail: customer.email,
|
||||
reminderLevel: fallbackLevel,
|
||||
success: false,
|
||||
error: 'Company settings not found',
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -243,16 +249,35 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
continue
|
||||
}
|
||||
|
||||
const reminderConfig = getReminderDaysConfig(company as CompanySettings)
|
||||
const reminderLevel = determineReminderLevel(daysOverdue, existingLevels, reminderConfig)
|
||||
|
||||
if (!reminderLevel) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: no reminder needed (${daysOverdue} days overdue, existing levels: ${existingLevels.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Race-window guard: re-check invoice status immediately before sending.
|
||||
// The cron runs at 08:00; a payment match arriving during the run shouldn't
|
||||
// produce a reminder for an already-paid invoice.
|
||||
const { data: currentInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('status')
|
||||
.select('status, credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', invoice.company_id)
|
||||
.single()
|
||||
|
||||
if (!currentInvoice || !['sent', 'overdue'].includes(currentInvoice.status as string)) {
|
||||
const currentCreditNotes = ((currentInvoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> } | null)?.credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (
|
||||
!currentInvoice ||
|
||||
!['sent', 'overdue'].includes(currentInvoice.status as string) ||
|
||||
currentCreditNotes.length > 0
|
||||
) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: status changed to ${currentInvoice?.status ?? 'unknown'} mid-run`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export type SettleInvoicePaymentResult =
|
||||
| { ok: false; code: 'INVOICE_PAID_LINES_UNBALANCED'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_NO_FISCAL_PERIOD'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_BOOK_FAILED'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_NOT_PAYABLE'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_RACE' }
|
||||
| { ok: false; code: 'BOOKKEEPING_ERROR'; error: unknown }
|
||||
| { ok: false; code: 'UPDATE_FAILED'; error: unknown }
|
||||
@@ -102,6 +103,14 @@ export async function settleInvoicePayment(
|
||||
settlementAccountNumber,
|
||||
} = params
|
||||
|
||||
if (invoice.credited_invoice_id) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVOICE_PAID_NOT_PAYABLE',
|
||||
details: { reason: 'credit_note' },
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Drive the JE shape from the invoice's actual booking state, not from
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user