Check/salary bankid api (#892)

* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup

- middleware: read BankID enrichment from the bankid_enrichment table (the
  extension_data path has been dead since the multi-tenant refactor), so
  company-less BankID users land on /select-company instead of the manual wizard
- BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the
  give-up limit; guard overlapping ticks so completion runs exactly once
  (a double /complete regenerated the magic link and invalidated the first,
  failing logins intermittently); retry clicks wait out the start cooldown
  instead of silently no-oping; Swedish messages for 429/unknown start errors
- bankid/complete: all-or-nothing signup — delete the created user when the
  identity insert, app_metadata update, or magic-link generation fails, so a
  retry starts clean instead of hitting account_exists with an unusable account
- bankid/unlink: read-merge-write app_metadata so has_password survives unlink
  (BankID-only users could otherwise strand themselves with no login method)
- login: BankID "create account" CTA now links to /register instead of
  dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: move secondary guides into docs/, delete dead root files

Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md
(renamed EXTENSIONS.md) into a new docs/ folder and update all path
references (README, setup.sh, .dockerignore image rules, docker-publish
workflow comment, _example-branding, lib/branding/service.ts).

Delete two dead root files: customer.json (stray API-test payload) and
findings.md (point-in-time swarm audit export, criticals already filed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(api): security & correctness hardening + withRouteContext MFA migration across API routes

Audit of ~100 app/api routes. Highlights:

Security
- agent/conversations: list leaked colleagues' titles + message previews
  (company-scoped RLS, no user filter) -> user-scoped
- calendar/feed PUT: raw body into .update() allowed feed_token fixation on a
  public unauthenticated URL -> strict schema, content toggles only
- bokslutsdispositioner: unbounded schablonintaktRate could inflate the
  IL 30 kap 25% periodiseringsfond cap base -> bounded
- agent profile/composer/onboarding: viewers could rewrite the agent profile
  while sibling /verify blocked them -> role-gated

Correctness
- account-totals / listAssets: unbounded queries silently truncated at 1000
  rows (under-counted money; skipped assets at year-end depreciation) ->
  fetchAllRows with stable order (+3 more pagination fixes)
- voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could
  show "no gaps" when the check never ran) -> surfaced
- 5 phantom-success writes (OK on zero matched rows) fixed
- assets K3 component-sum validated against stale acquisition_cost -> fixed
- invite silent email-send failure -> response carries email_sent;
  deadlines/calendar cast-then-check JSON crashes -> Zod

Convention
- ~44 legacy routes converted to withRouteContext (MFA); added Zod validation,
  corrected status codes, console.* -> lib/logger

Response shapes preserved for existing callers. ~110 new tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): save a booking as a reusable template from Bokför direkt

Add a "Spara som mall" action to the manual booking dialog so users can
capture a kontering they just worked out as a booking template — right
where they figured out how something should be booked.

- derive amount-parameterised template lines from the concrete booking
  (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with
  its rate snapped to the nearest standard rate, the rest = business
  ratios; line labels come from the loaded BAS chart)
- extract the shared TemplateForm out of BookingTemplatesPanel so the
  booking dialog reuses the same editor, live preview and convertibility
  hints instead of duplicating them
- save via the existing POST /api/settings/booking-templates endpoint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer

Bolagsverket rejected a user's filed årsredovisning with "Balansräkning
och resultaträkning ska inte innehålla kontonummer": the PDF built every
statement row as per-account "1930 Företagskonto" lines while the iXBRL
filing path already aggregated to statutory posts, so the two artifacts
diverged.

The PDF statements now derive from the same K2 risbs mapping the iXBRL
document uses (mapTrialBalancesToK2), via a new statement-rows.ts that
emits post-level rows in uppställningsform order for both the K2 and K3
templates. Also fixed along the way:

- Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load
  and render; the old PDF had no comparatives at all.
- mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass
  nudges) flow into ArsredovisningData.warnings so the wizard flags a
  non-fileable document before download.
- Flerårsöversikt current/previous year overridden with the mapper's
  strict-3000–3799 Nettoomsattning, mirroring build-input's
  duplicate-fact rule, so the FB table ties to the RR.
- FB eget kapital-table is post-level and drops obeskattade reserver
  (never eget kapital); K3 equity-changes statement uses real prior-year
  opening balances with derived utdelning/nyemission residuals that tie
  the roll-forward exactly to booked UB.
- build-input dedupes warnings now that the PDF path runs the same
  mapping.

Regression test asserts no RR/BR label ever contains a four-digit
account number again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports): diagnose untransferred prior-year results behind balance-sheet differens

Prod incident (97 kr): a multi-year SIE migration lacked one year's
omforing av arets resultat; the residual corrupted every later derived
opening balance and Balansrakningen showed a bare "Differens: 97 kr"
with no explanation. Continuity checking cannot catch this failure mode
(prior-year UB and derived IB match per-account by construction) - the
invariant that actually breaks is per-year P&L = 0 for all non-latest
years.

- lib/reports/imbalance-diagnosis.ts: shared detector
  (findUntransferredResults + buildImbalanceDiagnosis)
- Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced,
  naming the exact culprit years; rendered in web views + PDF; MCP
  gnubok_get_balance_sheet inherits the field via spread
- SIE import: parse-time warning when a completed year's vouchers leave
  a P&L residual, plus a post-import DB walk surfacing culprits as
  warnings and structured details.untransferredResults; the Arcim
  migration workspace previously dropped result.warnings entirely and
  now renders them
- opening-balance/correct: pre-flight the company lock date and return
  409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in
  the client message) instead of the retryable 500 that invited blind
  retries; catch-path maps a raced trigger rejection to the same code

Diagnosis runs only on unbalanced paths (zero cost when healthy) and
never fails the report or the import. No migration, nothing persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: production error remediation — FX rates, deadlines, log levels, correction relink

Batch of fixes for recurring Vercel runtime errors:

- Riksbanken FX rates: persistent read-through cache (exchange_rates
  table), one retry honoring Retry-After on 429/5xx, bounded ingest
  concurrency, and an honest fallback — most recent cached observation
  or null, never a hardcoded rate silently booked into amount_sek.
  Unrated transactions stay repairable via refresh-exchange-rate.
- Tax deadline regeneration inserts replacement rows before deleting
  the superseded set, so a failed insert no longer wipes a company's
  deadlines (the 23502 user_id regression did exactly that). Migration
  makes deadlines.user_id nullable for system-generated rows.
- Route wrappers + errorResponse log 4xx outcomes at warn so only
  genuine 5xx reach Vercel's runtime-error clustering; client-supplied
  /api/log telemetry demoted to warn as well.
- application/json documents (raw PSD2 responses archived per BFL)
  validate as parseable JSON with object/array root instead of always
  failing the magic-byte check.
- correctEntry surfaces document-relink failures to callers, and the
  BFL document-immutability trigger now allows relinking underlag from
  a reversed entry to its correction (migration + pg test).
- Middleware clears stale session cookies on /api requests too, using
  scope 'local' so cleanup doesn't re-trigger the failed token refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skatteverket): persist token health and stop retrying dead consents

Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE,
TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code
and timestamp — SKV per-flow refresh tokens live 65 minutes, so once
expired nothing recovers without a fresh BankID consent. The AGI
kvittens and skattekonto sync crons skip flagged connections instead of
failing every night, and the settings panel prompts for re-consent
proactively. A successful reconnect resets the row to active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts

A bank returning N same-currency accounts used to map them all onto the
currency default (1930/1932/1933/1934), tripping the UNIQUE
(company_id, ledger_account) constraint per-account — swallowed errors
left accounts silently unmirrored. allocatePsd2LedgerAccount now hands
out the currency default first, then free 1931–1959 sub-account slots,
skipping slots held by any existing row.

- Callback persists allocations to accounts_data so the picker pre-fills
  reality; reconnect reuses previously mirrored ledgers instead of
  re-deriving (a user remap to 1935 survives).
- Selection save resolves effective ledgers up front and rejects
  duplicates or cross-connection conflicts with a 400 instead of
  silently skipping the mirror.
- Bank error codes + psu_type are forwarded to the settings page for
  every OAuth error, keying the Handelsbanken corporate fullmakt
  guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent): stage exact journal lines on categorization previews

Categorization previews only carried debit/credit accounts, the GROSS
amount, and separate VAT rows — read together that looks like an
unbalanced 'gross on cost account + VAT debit' entry, and it misled
both users and agents into rejecting correct proposals. The MCP
preview and the pending-operation PATCH now materialize the exact
lines the commit executor will post (net cost line, VAT line, gross
bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives
them from the new mapping instead of spreading stale staged lines.
ApprovalCard and /pending render the verifikat lines, falling back to
the legacy summary only for operations staged before this fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): prune unused imported accounts from the chart

SIE imports routinely bring in hundreds of accounts that were never
used and clutter the kontoplan. New account_usage_counts RPC (one
grouped query instead of a count per account) backs GET
/api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune
deletes zero-usage accounts — dry-run first, then an explicit account
list capped at 2000. Accounts with journal lines are skipped, never
deleted. The chart manager shows a usage column and a prune dialog
grouping custom accounts vs unused BAS-seeded ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces

Credit-note creation now copies default_dimensions and per-line
dimensions from the original, so the reversing journal entry nets
against the same dimension cells instead of dropping them. List/detail
responses expose the dimension fields, and the OpenAPI spec snapshot
follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf: batch serial Supabase round-trips on hot dashboard paths

Every dashboard render pays the layout's query chain, so serialized
awaits are direct wall-clock: the layout, chat conversation, invoice
detail, supplier detail, select-company, and agent-onboarding pages now
run their independent lookups in parallel batches, and
getCompanyCapabilities folds its disabled-config read into the same
round-trip. JournalEntryList hydrates the saved fiscal-year scope
optimistically instead of serializing the first entries fetch behind
the fiscal-periods request. The supplier detail page filters invoices
server-side via a new supplier_id query param instead of fetching the
whole company ledger, and the invoice editor (with its framer-motion
dependency) lazy-loads so it stops shipping with the invoice list
bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(salary): one-click runs, payslip delivery, payments settings, run cockpit

Salary P1 batch, driving the 20-click flow toward 3 clicks:

- One-click 'Starta lönekörning': POST /api/salary/runs accepts an
  empty body and resolves defaults server-side — period follows the
  latest non-corrected run, payment date from the new
  salary_pay_day setting, series from the per-source-type map. The
  separate /salary/runs/new page is gone.
- Run detail page rebuilt as a step-railed cockpit (progress rail,
  KPI cards, employee ledger, journal preview) on a deliberately
  wider canvas; components extracted to components/salary/run/.
- Payslip delivery: tokenized public payslip pages (/payslip/[token],
  backed by salary_payslip_links) plus per-employee email send with
  PDF — employees need no account, and the middleware exempts the
  route from auth redirects.
- Payments settings: salary pay day, default bank, and pain.001 vs
  Bankgirot Lön format with per-bank upload instructions and an LB
  sunset warning (banks retire LB during 2026).
- AGI panel: full submission status flows (stale drafts, signing
  links, kvittens polling, error reports); tax payment panel with
  skattekonto shortcut and mark-as-paid.
- Salary calendar bulk editing, employee benefits/tax-card polish,
  municipality tax-table lookup improvements.

messages/sv+en also carry the strings for the account-prune,
skatteverket-reconsent, and banking surfaces committed just before
this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: adopt Next 16 proxy.ts convention + repo housekeeping

- Rename middleware.ts to proxy.ts with the proxy() export (Next 16
  renamed the middleware convention; behavior unchanged).
- Exclude dev_docs/ from tsconfig so stray snippets in planning docs
  don't break the build type-check.
- Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to
  lock in the withRouteContext migration from 5cfd2b76.
- template-library uses roundOre() instead of inline rounding.
- database.md: drop account_balances from the key-tables list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): robust service-role detection in correction document relink

relink_documents_to_correction() keyed its service-role branch on auth.role(),
which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the
pg-real harness no longer populate. Genuine service-role callers (pending-ops
executor / MCP approve) landed in the auth gate and could not relink underlag.
Read the role from the request.jwt.claims JSON directly, mirroring the canonical
link_voucher_rpcs_tenant_guard convention. Validated on staging.

Also: harden the salary run page's error paths (res.json().catch) against
non-JSON error bodies, and roll back the pg-real service-role case in finally so
an aborted transaction cannot poison a pooled connection for the next test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(documents): restore journal_entry_line_id link durability (BFL 7 kap)

Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to
guard journal_entry_id but left journal_entry_line_id to the metadata trigger,
which exempts draft-linked docs -- and the entry-level trigger only fired on
UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all.
That let a set journal_entry_line_id be cleared to NULL, breaking the "link
durable from first set" invariant (document-immutability.pg regression).

Widen the trigger to fire on journal_entry_line_id too and guard it with the
same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays
allowed; clearing/re-pointing a set value is blocked, status-independent). The
correction-relink GUC path, which legitimately clears line_id when moving
underlag to the posted correction, stays exempt. Validated on staging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-05 03:05:09 +02:00
committed by GitHub
parent ec27228a8e
commit 2c2743eb79
269 changed files with 17974 additions and 6481 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Use the `/supabase-migration` skill for new migrations.
## Key Tables (~60)
- **Multi-tenant**: `companies`, `company_members`, `company_invitations`, `teams`, `team_members`, `team_invitations`, `user_preferences`, `profiles`
- **Bookkeeping**: `chart_of_accounts`, `fiscal_periods`, `journal_entries`, `journal_entry_lines`, `account_balances`, `voucher_sequences`, `voucher_gap_explanations`
- **Bookkeeping**: `chart_of_accounts`, `fiscal_periods`, `journal_entries`, `journal_entry_lines`, `voucher_sequences`, `voucher_gap_explanations`
- **Invoicing**: `customers`, `invoices`, `invoice_items`, `invoice_payments`, `invoice_inbox_items`
- **Suppliers**: `suppliers`, `supplier_invoices`, `supplier_invoice_items`
- **Banking**: `bank_connections`, `transactions`, `bank_file_imports`, `payment_match_log`
+2 -1
View File
@@ -15,6 +15,7 @@ tests/
.vscode/
*.md
!README.md
!DOCKER.md
docs/*.md
!docs/DOCKER.md
LICENSE
docker-compose*.yml
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
# between publish and the blocking scan is the scan's own duration
# (minutes), not a 24h cron window) plus a daily cron as a safety net.
# Accepted residual risk: an image is live for that short scan window
# before the gate fires; see SELF-HOSTING.md / the risk register.
# before the gate fires; see docs/SELF-HOSTING.md / the risk register.
continue-on-error: true
uses: aquasecurity/trivy-action@v0.36.0
with:
+2 -2
View File
@@ -29,7 +29,7 @@ cd Accounted
docker compose up -d
```
You need a Supabase project and must apply the database migrations before first use. See [SELF-HOSTING.md](SELF-HOSTING.md) for the full step-by-step guide, including Supabase setup, auth configuration, optional features (AI, email, push notifications), and troubleshooting.
You need a Supabase project and must apply the database migrations before first use. See [SELF-HOSTING.md](docs/SELF-HOSTING.md) for the full step-by-step guide, including Supabase setup, auth configuration, optional features (AI, email, push notifications), and troubleshooting.
## Development Setup
@@ -52,7 +52,7 @@ npm run lint # ESLint
## Documentation
- [SELF-HOSTING.md](SELF-HOSTING.md) -- Full self-hosting guide (Docker, Supabase setup, migrations, optional features)
- [SELF-HOSTING.md](docs/SELF-HOSTING.md) -- Full self-hosting guide (Docker, Supabase setup, migrations, optional features)
- [CLAUDE.md](CLAUDE.md) -- Architecture, bookkeeping engine, database conventions, extension system
- [CONTRIBUTING.md](CONTRIBUTING.md) -- Development workflow, code style, pull request process
- [SECURITY.md](SECURITY.md) -- Vulnerability reporting policy
+3 -4
View File
@@ -412,13 +412,12 @@ function LoginPageContent() {
{tAuth('bankid_no_account_body')}
</p>
<p className="mt-2">
<button
type="button"
onClick={() => setBankIdNoAccount(null)}
<Link
href="/register"
className="text-xs text-amber-600 underline underline-offset-2 hover:text-amber-800 dark:text-amber-400"
>
{tAuth('bankid_no_account_create')}
</button>
</Link>
</p>
</div>
) : (
+16 -12
View File
@@ -23,21 +23,25 @@ export default async function ChatConversationPage({ params }: PageProps) {
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
const { data: conversation } = await supabase
.from('agent_conversations')
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
// Both queries key on the route id, so they run in parallel. The tenant
// check on the conversation row still gates rendering — when it fails,
// notFound() throws and the messages result is discarded unrendered.
const [{ data: conversation }, { data: messages }] = await Promise.all([
supabase
.from('agent_conversations')
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('agent_messages')
.select('role, content, hidden, created_at')
.eq('conversation_id', id)
.order('created_at', { ascending: true }),
])
if (!conversation) notFound()
const { data: messages } = await supabase
.from('agent_messages')
.select('role, content, hidden, created_at')
.eq('conversation_id', id)
.order('created_at', { ascending: true })
return (
<ChatConversationView
conversationId={id}
+77 -77
View File
@@ -127,15 +127,36 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
async function fetchInvoice() {
setIsLoading(true)
const { data, error } = await supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', id)
.single()
// Invoice, reminders, and payments all key on the route id — one
// parallel batch. Only the follow-ups below need the invoice row.
const [{ data, error }, { data: reminderData }, { data: paymentData }] =
await Promise.all([
supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', id)
.single(),
supabase
.from('invoice_reminders')
.select('*')
.eq('invoice_id', id)
.order('sent_at', { ascending: false }),
// Payment history for the Betalningsstatus card. Joins the
// journal_entries row to get voucher_series + voucher_number so each
// payment row can link to its verifikat. Manual payments (no tx, no
// JE) still surface with the amount + date.
supabase
.from('invoice_payments')
.select(
'id, payment_date, amount, currency, journal_entry_id, journal_entries(voucher_series, voucher_number)',
)
.eq('invoice_id', id)
.order('payment_date', { ascending: true }),
])
if (error || !data) {
toast({
@@ -154,45 +175,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setInvoice(data as InvoiceWithRelations)
// Fetch the öresavrundning + VAT-registration settings so the detail view
// matches the PDF (pdf-template.tsx:792 hides org_number / personnummer
// for private customers, and :876 suppresses the moms row when the seller
// is not VAT-registered and the invoice carries no VAT).
if (data.company_id) {
const { data: settings } = await supabase
.from('company_settings')
.select('ore_rounding, vat_registered')
.eq('company_id', data.company_id)
.maybeSingle()
setOreRounding(settings?.ore_rounding ?? true)
if (typeof settings?.vat_registered === 'boolean') {
setVatRegistered(settings.vat_registered)
}
}
// Fetch reminders for this invoice
const { data: reminderData } = await supabase
.from('invoice_reminders')
.select('*')
.eq('invoice_id', id)
.order('sent_at', { ascending: false })
if (reminderData) {
setReminders(reminderData as InvoiceReminder[])
}
// Fetch payment history for the Betalningsstatus card. Joins the
// journal_entries row to get voucher_series + voucher_number so each
// payment row can link to its verifikat. Manual payments (no tx, no
// JE) still surface with the amount + date.
const { data: paymentData } = await supabase
.from('invoice_payments')
.select(
'id, payment_date, amount, currency, journal_entry_id, journal_entries(voucher_series, voucher_number)',
)
.eq('invoice_id', id)
.order('payment_date', { ascending: true })
if (paymentData) {
type PaymentRow = {
id: string
@@ -215,43 +201,57 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)
}
// If this invoice is credited, find the credit note
if (data.status === 'credited') {
const { data: creditNoteData } = await supabase
.from('invoices')
.select('id, invoice_number')
.eq('credited_invoice_id', id)
.single()
// Follow-ups that need the invoice row: company settings (öresavrundning
// + VAT registration so the detail view matches the PDF — see
// pdf-template.tsx:792 and :876), the credit note, the original invoice,
// and the proforma source. Independent of each other → parallel.
const [settingsRes, creditNoteRes, originalRes, convertedRes] =
await Promise.all([
data.company_id
? supabase
.from('company_settings')
.select('ore_rounding, vat_registered')
.eq('company_id', data.company_id)
.maybeSingle()
: Promise.resolve(null),
data.status === 'credited'
? supabase
.from('invoices')
.select('id, invoice_number')
.eq('credited_invoice_id', id)
.single()
: Promise.resolve(null),
data.credited_invoice_id
? supabase
.from('invoices')
.select('id, invoice_number')
.eq('id', data.credited_invoice_id)
.single()
: Promise.resolve(null),
data.converted_from_id
? supabase
.from('invoices')
.select('id, invoice_number')
.eq('id', data.converted_from_id)
.single()
: Promise.resolve(null),
])
if (creditNoteData) {
setCreditNote(creditNoteData as Invoice)
if (settingsRes) {
const settings = settingsRes.data
setOreRounding(settings?.ore_rounding ?? true)
if (typeof settings?.vat_registered === 'boolean') {
setVatRegistered(settings.vat_registered)
}
}
// If this is a credit note, fetch the original invoice
if (data.credited_invoice_id) {
const { data: originalData } = await supabase
.from('invoices')
.select('id, invoice_number')
.eq('id', data.credited_invoice_id)
.single()
if (originalData) {
setOriginalInvoice(originalData as Invoice)
}
if (creditNoteRes?.data) {
setCreditNote(creditNoteRes.data as Invoice)
}
// If this invoice was converted from a proforma, fetch it
if (data.converted_from_id) {
const { data: convertedData } = await supabase
.from('invoices')
.select('id, invoice_number')
.eq('id', data.converted_from_id)
.single()
if (convertedData) {
setConvertedFromInvoice(convertedData as Invoice)
}
if (originalRes?.data) {
setOriginalInvoice(originalRes.data as Invoice)
}
if (convertedRes?.data) {
setConvertedFromInvoice(convertedRes.data as Invoice)
}
setIsLoading(false)
+49 -54
View File
@@ -48,33 +48,28 @@ export default async function DashboardLayout({
// `getActiveCompanyId` reads from user_preferences, matching what RLS
// sees via `current_active_company_id()`. Keeping both sides on the same
// source avoids cross-tab / cookie divergence.
const companyId = await getActiveCompanyId(supabase, user.id)
// Team membership (with the team row embedded) only depends on user.id,
// so it resolves in parallel, this layout is on the critical path of
// every dashboard page, so sequential round-trips are wall-clock time.
const [companyId, headerStore, { data: teamMembership }] = await Promise.all([
getActiveCompanyId(supabase, user.id),
// Read the pathname forwarded by middleware so we can branch on it.
headers(),
supabase
.from('team_members')
.select('team_id, role, teams:team_id(*)')
.eq('user_id', user.id)
.limit(1)
.maybeSingle(),
])
// Read the pathname forwarded by middleware so we can branch on it.
const headerStore = await headers()
const pathname = headerStore.get('x-pathname') ?? ''
const isNoCompanyAllowed = NO_COMPANY_ALLOWED_PATHS.some((p) =>
pathname.startsWith(p)
)
// Fetch team membership + team info
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id, role')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
let team: Team | null = null
if (teamMembership?.team_id) {
const { data: teamRow } = await supabase
.from('teams')
.select('*')
.eq('id', teamMembership.team_id)
.single()
team = teamRow
}
const team: Team | null =
(teamMembership?.teams as unknown as Team | null) ?? null
const isTeamMember = !!teamMembership
// No companies: redirect to onboarding, except for allowed escape-hatch
@@ -125,15 +120,47 @@ export default async function DashboardLayout({
)
}
// Fetch company + membership for context provider
// Fetch company + membership for context provider, together with the
// nav/badge data, none of these depend on each other, only on
// companyId/user.id, so one round-trip batch instead of two. The rare
// stale-cookie early return below wastes the extra reads; that's cheaper
// than serializing two batches on every dashboard render.
const [
{ data: companyRow },
{ data: memberRow },
{ data: allMemberships },
{ data: settings },
uncategorizedCount,
pendingOpsCount,
{ data: agentProfileIdentity },
{ data: userProfile },
capabilities,
] = await Promise.all([
supabase.from('companies').select('*').eq('id', companyId).single(),
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id),
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
.eq('company_id', companyId)
.single(),
// Shared worklist predicates (lib/worklist), the badge must show the
// same number as every other "att göra" surface. Notably this excludes
// is_ignored rows, which the old inline query here did not.
countUnbookedTransactions(supabase, companyId),
countPendingOperations(supabase, companyId),
// Agent identity, name + avatar, surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path).
supabase
.from('agent_profiles')
.select('display_name, avatar_id, verified_at')
.eq('company_id', companyId)
.maybeSingle(),
// The signed-in user's profile, shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
getCompanyCapabilities(supabase, companyId),
])
if (!companyRow || !memberRow) {
@@ -178,38 +205,6 @@ export default async function DashboardLayout({
)
}
const [
{ data: settings },
uncategorizedCount,
pendingOpsCount,
{ data: agentProfileIdentity },
{ data: userProfile },
capabilities,
] = await Promise.all([
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
.eq('company_id', companyId)
.single(),
// Shared worklist predicates (lib/worklist): the badge must show the
// same number as every other "att göra" surface. Notably this excludes
// is_ignored rows, which the old inline query here did not.
countUnbookedTransactions(supabase, companyId),
countPendingOperations(supabase, companyId),
// Agent identity (name + avatar) surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path).
supabase
.from('agent_profiles')
.select('display_name, avatar_id, verified_at')
.eq('company_id', companyId)
.maybeSingle(),
// The signed-in user's profile: shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
getCompanyCapabilities(supabase, companyId),
])
// If onboarding incomplete, still render the dashboard: the page component
// will show the inline onboarding card instead of the normal dashboard content.
+25
View File
@@ -223,8 +223,33 @@ function formatRelativeTime(dateStr: string): string {
}
function CategorizePreview({ data }: { data: Record<string, unknown> }) {
// The exact journal lines the approval will post (net cost line, VAT line,
// gross bank line, SEK) — staged by the server since the preview-lines fix.
const lines = (data.lines as Array<{ account_number?: string; debit_amount?: number; credit_amount?: number; description?: string }>) || []
const vatLines = (data.vat_lines as Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>) || []
if (lines.length > 0) {
return (
<div className="space-y-1 text-sm">
<p className="text-xs text-muted-foreground mb-1">Verifikat</p>
{lines.map((line, i) => {
const debitAmt = typeof line.debit_amount === 'number' ? line.debit_amount : 0
const creditAmt = typeof line.credit_amount === 'number' ? line.credit_amount : 0
return (
<div key={i} className="flex justify-between gap-4 font-mono text-xs">
<span className="truncate">{line.account_number ?? '?'}{line.description ? ` ${line.description}` : ''}</span>
<span className="tabular-nums shrink-0">
{debitAmt > 0 ? `D ${formatCurrency(debitAmt)}` : `K ${formatCurrency(creditAmt)}`}
</span>
</div>
)
})}
</div>
)
}
// Legacy summary for operations staged before the preview carried full
// lines: debit/credit accounts + gross amount + separate VAT rows.
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
+101 -136
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from "@/components/ui/skeleton"
import { Button } from '@/components/ui/button'
@@ -18,10 +19,10 @@ import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
const EMPLOYMENT_LABELS: Record<string, string> = {
employee: 'Anställd',
company_owner: 'Företagsledare',
board_member: 'Styrelseledamot',
const EMPLOYMENT_LABEL_KEYS: Record<string, string> = {
employee: 'form_employment_type_employee',
company_owner: 'form_employment_type_company_owner',
board_member: 'form_employment_type_board_member',
}
function RequiredMark() {
@@ -30,6 +31,7 @@ function RequiredMark() {
export default function EmployeeDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const t = useTranslations('salary_employee')
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
@@ -126,11 +128,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
if (res.ok) {
const { data } = await res.json()
setEmployee(data)
toast({ title: 'Anställd uppdaterad' })
toast({ title: t('detail_updated') })
} else {
const result = await res.json()
toast({
title: 'Kunde inte uppdatera anställd',
title: t('detail_update_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
@@ -140,11 +142,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
}
async function handleDeactivate() {
if (!confirm('Vill du inaktivera denna anställd?')) return
if (!confirm(t('detail_deactivate_confirm'))) return
const res = await fetch(`/api/salary/employees/${id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Anställd inaktiverad' })
toast({ title: t('detail_deactivated') })
router.push('/salary/employees')
}
}
@@ -159,7 +161,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
}
if (!employee) {
return <p className="text-muted-foreground">Anställd hittades inte</p>
return <p className="text-muted-foreground">{t('detail_not_found')}</p>
}
return (
@@ -167,149 +169,160 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/salary/employees" aria-label="Tillbaka till anställda"><ArrowLeft className="h-4 w-4" /></Link>
<Link href="/salary/employees" aria-label={t('form_back_to_employees')}><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div>
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
{employee.first_name} {employee.last_name}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{employee.personnummer} · {EMPLOYMENT_LABELS[employee.employment_type]}
{employee.personnummer} · {t(EMPLOYMENT_LABEL_KEYS[employee.employment_type])}
</p>
</div>
</div>
{canWrite && (
<Button variant="outline" size="sm" onClick={handleDeactivate} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Inaktivera
{t('detail_deactivate')}
</Button>
)}
</div>
<form onSubmit={handleSave} className="space-y-6">
{/* Personal info */}
<form onSubmit={handleSave} className="space-y-4">
{/* Person & kontakt - name, contact, and address in one dense card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Personuppgifter</CardTitle>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('form_personal_info')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">Förnamn<RequiredMark /></Label>
<Label htmlFor="first_name">{t('form_first_name')}<RequiredMark /></Label>
<Input id="first_name" name="first_name" defaultValue={employee.first_name} required disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Efternamn<RequiredMark /></Label>
<Label htmlFor="last_name">{t('form_last_name')}<RequiredMark /></Label>
<Input id="last_name" name="last_name" defaultValue={employee.last_name} required disabled={!canWrite} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Label htmlFor="email">{t('form_email')}</Label>
<Input id="email" name="email" type="email" defaultValue={employee.email || ''} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">Krävs för att skicka lönebesked</p>
<p className="text-xs text-muted-foreground">{t('form_email_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Label htmlFor="phone">{t('form_phone')}</Label>
<Input id="phone" name="phone" defaultValue={employee.phone || ''} disabled={!canWrite} />
</div>
</div>
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<CardTitle className="text-base">Adress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Input id="address_line1" name="address_line1" defaultValue={employee.address_line1 || ''} disabled={!canWrite} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="address_line1">{t('form_street_address')}</Label>
<Input id="address_line1" name="address_line1" defaultValue={employee.address_line1 || ''} disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Label htmlFor="postal_code">{t('form_postal_code')}</Label>
<Input id="postal_code" name="postal_code" defaultValue={employee.postal_code || ''} className="max-w-[160px]" disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Label htmlFor="city">{t('form_city')}</Label>
<Input id="city" name="city" defaultValue={employee.city || ''} disabled={!canWrite} />
</div>
</div>
</CardContent>
</Card>
{/* Employment */}
{/* Anställning & lön - employment terms, salary, and vacation together */}
<Card>
<CardHeader>
<CardTitle className="text-base">Anställning</CardTitle>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('form_employment_salary')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="employment_type">Typ</Label>
<Label htmlFor="employment_type">{t('form_employment_type')}</Label>
<Select value={employmentType} onValueChange={setEmploymentType} disabled={!canWrite}>
<SelectTrigger id="employment_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="employee">Anställd</SelectItem>
<SelectItem value="company_owner">Företagsledare</SelectItem>
<SelectItem value="board_member">Styrelseledamot</SelectItem>
<SelectItem value="employee">{t('form_employment_type_employee')}</SelectItem>
<SelectItem value="company_owner">{t('form_employment_type_company_owner')}</SelectItem>
<SelectItem value="board_member">{t('form_employment_type_board_member')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
<Label htmlFor="employment_degree">{t('form_employment_degree')}</Label>
<Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} min="1" max="100" disabled={!canWrite} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="employment_start">Anställningsdatum<RequiredMark /></Label>
<Input id="employment_start" name="employment_start" type="date" defaultValue={employee.employment_start || ''} required disabled={!canWrite} />
<p className="text-xs text-muted-foreground">Lönen proportioneras automatiskt om anställningen börjar eller slutar mitt i en löneperiod.</p>
</div>
<div className="space-y-2">
<Label htmlFor="employment_end">Slutdatum</Label>
<Input id="employment_end" name="employment_end" type="date" defaultValue={employee.employment_end || ''} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">Lämna tomt för pågående anställning.</p>
</div>
</div>
</CardContent>
</Card>
{/* Salary */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lön</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="salary_type">Löneform<RequiredMark /></Label>
<Label htmlFor="salary_type">{t('form_salary_type')}<RequiredMark /></Label>
<Select value={salaryType} onValueChange={setSalaryType} disabled={!canWrite}>
<SelectTrigger id="salary_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månadslön</SelectItem>
<SelectItem value="hourly">Timlön</SelectItem>
<SelectItem value="monthly">{t('form_salary_type_monthly')}</SelectItem>
<SelectItem value="hourly">{t('form_salary_type_hourly')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_start">{t('form_employment_start')}<RequiredMark /></Label>
<Input id="employment_start" name="employment_start" type="date" defaultValue={employee.employment_start || ''} required disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('detail_employment_start_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="employment_end">{t('form_employment_end')}</Label>
<Input id="employment_end" name="employment_end" type="date" defaultValue={employee.employment_end || ''} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('detail_employment_end_hint')}</p>
</div>
{salaryType === 'monthly' ? (
<div className="space-y-2">
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)<RequiredMark /></Label>
<Label htmlFor="monthly_salary">{t('form_monthly_salary')}<RequiredMark /></Label>
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" defaultValue={employee.monthly_salary || ''} required disabled={!canWrite} />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="hourly_rate">Timlön (SEK)<RequiredMark /></Label>
<Label htmlFor="hourly_rate">{t('form_hourly_rate')}<RequiredMark /></Label>
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" defaultValue={employee.hourly_rate || ''} required disabled={!canWrite} />
</div>
)}
<div className="space-y-2 md:col-span-2">
<Label htmlFor="vacation_rule">{t('form_vacation_rule')}</Label>
<Select value={vacationRule} onValueChange={setVacationRule} disabled={!canWrite}>
<SelectTrigger id="vacation_rule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="procentregeln">{t('form_vacation_rule_procentregeln')}</SelectItem>
<SelectItem value="sammaloneregeln">{t('form_vacation_rule_sammaloneregeln')}</SelectItem>
<SelectItem value="semesterersattning">{t('form_vacation_rule_semesterersattning')}</SelectItem>
<SelectItem value="none">{t('form_vacation_rule_none')}</SelectItem>
</SelectContent>
</Select>
{vacationRule === 'none' && (
<p className="text-xs text-muted-foreground">
{t('detail_vacation_none_hint')}
</p>
)}
{vacationRule === 'semesterersattning' && (
<p className="text-xs text-muted-foreground">
{t('form_vacation_semesterersattning_hint')}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vacation_days_per_year">{t('form_vacation_days')}</Label>
<Input
id="vacation_days_per_year"
name="vacation_days_per_year"
type="number"
min="25"
max="40"
defaultValue={employee.vacation_days_per_year}
disabled={!canWrite}
/>
<p className="text-xs text-muted-foreground">{t('form_vacation_days_hint')}</p>
</div>
</div>
</CardContent>
</Card>
@@ -317,13 +330,13 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
{/* Default dimensions (kostnadsställe/projekt) */}
{dimensionsEnabled && (
<Card>
<CardHeader>
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('form_dimensions_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<LineDimensionFields dimensions={dimensions} onChange={setDimension} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">
Föreslås lönekostnadsrader vid bokföring av lönekörningar.
{t('form_dimensions_hint')}
</p>
</CardContent>
</Card>
@@ -344,75 +357,27 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
/>
{employee.f_skatt_verified_at && (
<p className="-mt-2 text-xs text-muted-foreground">
F-skatt verifierad: {new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE')}
{t('detail_f_skatt_verified', { date: new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE') })}
</p>
)}
{/* Vacation */}
<Card>
<CardHeader>
<CardTitle className="text-base">Semester</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vacation_rule">Semesterregel</Label>
<Select value={vacationRule} onValueChange={setVacationRule} disabled={!canWrite}>
<SelectTrigger id="vacation_rule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
<SelectItem value="semesterersattning">Semesterersättning (betalas ut direkt)</SelectItem>
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
</SelectContent>
</Select>
{vacationRule === 'none' && (
<p className="text-xs text-muted-foreground">
Ingen avsättning till 2920 bokas. Använd om semester ingår i månadslönen, vanligt för ägare som är enda anställd.
</p>
)}
{vacationRule === 'semesterersattning' && (
<p className="text-xs text-muted-foreground">
12 % läggs varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp, vanligt för tim- och visstidsanställda.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
<Input
id="vacation_days_per_year"
name="vacation_days_per_year"
type="number"
min="25"
max="40"
defaultValue={employee.vacation_days_per_year}
disabled={!canWrite}
/>
<p className="text-xs text-muted-foreground">Lagstadgat minimum: 25 dagar</p>
</div>
</div>
</CardContent>
</Card>
{/* Bank */}
<Card>
<CardHeader>
<CardTitle className="text-base">Bankkonto</CardTitle>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('form_bank_account')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearingnummer</Label>
<Label htmlFor="clearing_number">{t('form_clearing_number')}</Label>
<Input id="clearing_number" name="clearing_number" defaultValue={employee.clearing_number || ''} disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="bank_account_number">Kontonummer</Label>
<Label htmlFor="bank_account_number">{t('form_account_number')}</Label>
<Input id="bank_account_number" name="bank_account_number" defaultValue={employee.bank_account_number || ''} disabled={!canWrite} />
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
<p className="text-xs text-muted-foreground mt-2">{t('form_bank_hint')}</p>
</CardContent>
</Card>
@@ -422,11 +387,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
{canWrite && (
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link href="/salary/employees">Avbryt</Link>
<Link href="/salary/employees">{t('form_cancel')}</Link>
</Button>
<Button type="submit" disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Sparar...' : 'Spara ändringar'}
{saving ? t('form_saving') : t('detail_save_changes')}
</Button>
</div>
)}
+320 -74
View File
@@ -1,21 +1,26 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from "@/components/ui/skeleton"
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, Users, HandCoins, CalendarDays, ArrowRight } from 'lucide-react'
import { ArrowRight, CalendarClock, HandCoins, Loader2, Plus, UserX, Users } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import NewSalaryRunDialog from '@/components/salary/NewSalaryRunDialog'
import type { SalaryRun } from '@/types'
import type { Employee, SalaryRun } from '@/types'
const supabase = createClient()
const STATUS_LABEL_KEYS: Record<string, string> = {
draft: 'status_draft',
@@ -23,58 +28,125 @@ const STATUS_LABEL_KEYS: Record<string, string> = {
approved: 'status_approved',
paid: 'status_paid',
booked: 'status_booked',
corrected: 'status_corrected',
}
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
draft: 'secondary',
review: 'warning',
approved: 'default',
paid: 'success',
booked: 'success',
corrected: 'outline',
}
interface TaxPaymentState {
tax_payment_file_generated_at: string | null
tax_paid_at: string | null
}
export default function SalaryPage() {
const [runs, setRuns] = useState<SalaryRun[]>([])
const [employeeCount, setEmployeeCount] = useState(0)
const [employees, setEmployees] = useState<Employee[]>([])
const [payDay, setPayDay] = useState(25)
const [agiDeadline, setAgiDeadline] = useState<{ due_date: string; title: string } | null>(null)
const [taxPayment, setTaxPayment] = useState<TaxPaymentState | null>(null)
const [loading, setLoading] = useState(true)
const [starting, setStarting] = useState(false)
const { canWrite } = useCanWrite()
const { company } = useCompany()
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const t = useTranslations('salary')
// The "Ny lönekörning" modal is driven by the URL (?new=1) so every entry
// point (the header button, the empty state, and the legacy
// /salary/runs/new redirect) opens the same dialog, and the browser back
// button closes it. Same pattern as /invoices.
const showNewRun = searchParams.has('new')
const closeNewRun = () => router.replace('/salary', { scroll: false })
const openNewRun = () => router.push('/salary?new=1', { scroll: false })
const load = useCallback(async () => {
const [runsRes, empRes, settingsRes] = await Promise.all([
fetch('/api/salary/runs'),
fetch('/api/salary/employees'),
fetch('/api/settings'),
])
useEffect(() => {
async function load() {
const [runsRes, empRes] = await Promise.all([
fetch('/api/salary/runs'),
fetch('/api/salary/employees'),
])
if (runsRes.ok) {
const { data } = await runsRes.json()
setRuns(data || [])
}
if (empRes.ok) {
const { data } = await empRes.json()
setEmployeeCount((data || []).length)
}
setLoading(false)
let loadedRuns: SalaryRun[] = []
if (runsRes.ok) {
const { data } = await runsRes.json()
loadedRuns = data || []
setRuns(loadedRuns)
}
load()
if (empRes.ok) {
const { data } = await empRes.json()
setEmployees(data || [])
}
if (settingsRes.ok) {
const { data } = await settingsRes.json()
if (typeof data?.salary_pay_day === 'number') setPayDay(data.salary_pay_day)
}
// Latest booked run drives the "skatt att betala" card.
const latestBooked = loadedRuns.find(r => r.status === 'booked')
if (latestBooked) {
const period = `${latestBooked.period_year}-${String(latestBooked.period_month).padStart(2, '0')}`
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
if (txRes.ok) {
const tx = await txRes.json()
setTaxPayment(tx.data)
}
}
setLoading(false)
}, [])
const currentYear = new Date().getFullYear()
const yearRuns = runs.filter(r => r.period_year === currentYear)
const totalGrossYTD = yearRuns.filter(r => r.status === 'booked').reduce((sum, r) => sum + r.total_gross, 0)
const totalAvgifterYTD = yearRuns.filter(r => r.status === 'booked').reduce((sum, r) => sum + r.total_avgifter, 0)
const latestRun = runs[0]
useEffect(() => {
load()
}, [load])
// Next open AGI deadline instance - generated by the tax-deadline engine
// when the company pays salaries; same source as the /deadlines page.
useEffect(() => {
if (!company) return
const today = new Date().toISOString().split('T')[0]
supabase
.from('deadlines')
.select('due_date, title')
.eq('company_id', company.id)
.eq('tax_deadline_type', 'arbetsgivardeklaration')
.eq('is_completed', false)
.gte('due_date', today)
.order('due_date')
.limit(1)
.maybeSingle()
.then(({ data }) => setAgiDeadline(data ?? null))
}, [company])
// One-click run creation: the API seeds all active employees, calculates,
// and resolves period/pay-date/series defaults from settings.
async function startRun() {
setStarting(true)
try {
const res = await fetch('/api/salary/runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
const json = await res.json().catch(() => null)
if (res.status === 201 && json?.data?.id) {
router.push(`/salary/runs/${json.data.id}`)
return
}
const existingId = json?.error?.details?.existingId
if (res.status === 409 && existingId) {
toast({ title: t('run_exists_opening') })
router.push(`/salary/runs/${existingId}`)
return
}
toast({
title: t('start_run_failed'),
description: getErrorMessage(json, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
} finally {
setStarting(false)
}
}
if (loading) {
return (
@@ -83,6 +155,7 @@ export default function SalaryPage() {
<Skeleton className="h-9 w-40" />
<Skeleton className="h-9 w-32" />
</div>
<Skeleton className="h-28 rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-24 rounded-lg" />
@@ -92,6 +165,94 @@ export default function SalaryPage() {
)
}
// ── Hero state machine (first match wins) ────────────────────────────────
const activeRun = runs.find(r => r.status !== 'corrected')
const latestBooked = runs.find(r => r.status === 'booked')
const periodOf = (r: SalaryRun) => `${r.period_year}-${String(r.period_month).padStart(2, '0')}`
// Next period for the quiet state: month after the latest non-corrected run.
const nextPeriod = (() => {
if (!activeRun) {
const now = new Date()
return { year: now.getFullYear(), month: now.getMonth() + 1 }
}
return activeRun.period_month === 12
? { year: activeRun.period_year + 1, month: 1 }
: { year: activeRun.period_year, month: activeRun.period_month + 1 }
})()
const nextPayDate = `${nextPeriod.year}-${String(nextPeriod.month).padStart(2, '0')}-${String(payDay).padStart(2, '0')}`
type Hero =
| { kind: 'onboarding' }
| { kind: 'cta'; title: string; description: string; label: string; runId: string }
| { kind: 'quiet' }
const hero: Hero = (() => {
if (runs.length === 0 && employees.length === 0) return { kind: 'onboarding' }
if (activeRun && (activeRun.status === 'draft' || activeRun.status === 'review')) {
return {
kind: 'cta',
title: t('hero_review_title', { period: periodOf(activeRun) }),
description: t('hero_review_description', {
count: (activeRun as SalaryRun & { employees?: unknown[] }).employees?.length ?? employees.length,
net: formatCurrency(activeRun.total_net),
date: formatDate(activeRun.payment_date),
}),
label: t('hero_review_action'),
runId: activeRun.id,
}
}
if (activeRun && activeRun.status === 'approved') {
// A run that pays out nothing (nollkörning, or fully net-deducted) has no
// payment file to download - don't send the user to "pay". The real next
// step is to post it and file AGI, so shepherd them into the run instead.
const noPayout = Math.round((activeRun.total_net ?? 0) * 100) === 0
if (noPayout) {
return {
kind: 'cta',
title: t('hero_finish_title', { period: periodOf(activeRun) }),
description: t('hero_finish_description'),
label: t('hero_finish_action'),
runId: activeRun.id,
}
}
return {
kind: 'cta',
title: t('hero_pay_title', { period: periodOf(activeRun) }),
description: t('hero_pay_description', {
net: formatCurrency(activeRun.total_net),
date: formatDate(activeRun.payment_date),
}),
label: t('hero_pay_action'),
runId: activeRun.id,
}
}
if (activeRun && activeRun.status === 'paid') {
return {
kind: 'cta',
title: t('hero_book_title', { period: periodOf(activeRun) }),
description: t('hero_book_description'),
label: t('hero_book_action'),
runId: activeRun.id,
}
}
if (activeRun && activeRun.status === 'booked' && !activeRun.agi_submitted_at) {
return {
kind: 'cta',
title: t('hero_agi_title', { period: periodOf(activeRun) }),
description: t('hero_agi_description'),
label: t('hero_agi_action'),
runId: activeRun.id,
}
}
return { kind: 'quiet' }
})()
// ── Blockers: active employees missing what a run needs ──────────────────
const missingBank = employees.filter(e => !e.clearing_number || !e.bank_account_number).length
const missingEmail = employees.filter(e => !e.email).length
const blockerCount = missingBank + missingEmail
return (
<div className="space-y-8">
<PageHeader
@@ -105,53 +266,145 @@ export default function SalaryPage() {
</Link>
</Button>
{canWrite && (
<Button onClick={openNewRun}>
<Plus className="mr-2 h-4 w-4" />
{t('new_run')}
<Button onClick={startRun} disabled={starting}>
{starting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{t('start_run')}
</Button>
)}
</div>
}
/>
{/* Summary cards */}
{/* Hero - the one thing to do now */}
{hero.kind === 'onboarding' ? (
<Card>
<CardContent className="p-0">
<EmptyState
icon={Users}
title={t('onboarding_title')}
description={t('onboarding_description')}
actionLabel={canWrite ? t('onboarding_action') : undefined}
actionHref={canWrite ? '/salary/employees/new' : undefined}
/>
</CardContent>
</Card>
) : hero.kind === 'cta' ? (
<Card>
<CardContent className="p-6 flex flex-col md:flex-row md:items-center gap-4 justify-between">
<div className="space-y-1 min-w-0">
<h2 className="font-display text-xl md:text-2xl tracking-tight">{hero.title}</h2>
<p className="text-sm text-muted-foreground">{hero.description}</p>
</div>
<Button asChild className="shrink-0">
<Link href={`/salary/runs/${hero.runId}`}>
{hero.label}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-6 flex flex-col md:flex-row md:items-center gap-4 justify-between">
<div className="space-y-1 min-w-0">
<h2 className="font-display text-xl md:text-2xl tracking-tight">
{t('quiet_title', {
period: `${nextPeriod.year}-${String(nextPeriod.month).padStart(2, '0')}`,
})}
</h2>
<p className="text-sm text-muted-foreground">
{t('quiet_description', { date: formatDate(nextPayDate) })}
</p>
</div>
{canWrite && (
<Button variant="outline" onClick={startRun} disabled={starting} className="shrink-0">
{starting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('quiet_action')}
</Button>
)}
</CardContent>
</Card>
)}
{/* Attention cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">{t('employees')}</p>
<p className="text-2xl font-semibold tabular-nums">{employeeCount}</p>
</div>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<CalendarClock className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{t('card_agi_title')}</p>
</div>
{agiDeadline ? (
<>
<p className="font-sans text-lg font-medium tabular-nums leading-tight">
{formatDate(agiDeadline.due_date)}
</p>
<Link
href="/deadlines"
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{agiDeadline.title}
</Link>
</>
) : (
<p className="text-sm text-muted-foreground">{t('card_agi_none')}</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3">
<HandCoins className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">{t('gross_year', { year: currentYear })}</p>
<p className="text-2xl font-semibold tabular-nums">{formatCurrency(totalGrossYTD)}</p>
</div>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<HandCoins className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{t('card_tax_title')}</p>
</div>
{latestBooked ? (
<>
<p className="font-sans text-lg font-medium tabular-nums leading-tight">
{formatCurrency(latestBooked.total_tax + latestBooked.total_avgifter)}
</p>
<p className="text-xs text-muted-foreground">
{taxPayment?.tax_paid_at
? t('card_tax_paid', { date: formatDate(taxPayment.tax_paid_at) })
: t('card_tax_unpaid', { period: periodOf(latestBooked) })}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t('card_tax_none')}</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3">
<CalendarDays className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">{t('contributions_year', { year: currentYear })}</p>
<p className="text-2xl font-semibold tabular-nums">{formatCurrency(totalAvgifterYTD)}</p>
</div>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<UserX className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{t('card_blockers_title')}</p>
</div>
{blockerCount > 0 ? (
<>
<p className="font-sans text-lg font-medium tabular-nums leading-tight">
{blockerCount}
</p>
<Link
href="/salary/employees"
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{t('card_blockers_detail', { bank: missingBank, email: missingEmail })}
</Link>
</>
) : (
<p className="text-sm text-muted-foreground">{t('card_blockers_none')}</p>
)}
</CardContent>
</Card>
</div>
{/* Recent runs */}
{/* History */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('runs_title')}</CardTitle>
@@ -162,8 +415,8 @@ export default function SalaryPage() {
icon={HandCoins}
title={t('empty_runs_title')}
description={t('empty_runs_description')}
actionLabel={canWrite ? t('create_run') : undefined}
onAction={canWrite ? openNewRun : undefined}
actionLabel={canWrite ? t('start_run') : undefined}
onAction={canWrite ? startRun : undefined}
/>
) : (
<Table>
@@ -182,7 +435,7 @@ export default function SalaryPage() {
{runs.slice(0, 12).map(run => (
<TableRow key={run.id}>
<TableCell className="font-medium tabular-nums">
{run.period_year}-{String(run.period_month).padStart(2, '0')}
{periodOf(run)}
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">
{formatDate(run.payment_date)}
@@ -213,13 +466,6 @@ export default function SalaryPage() {
)}
</CardContent>
</Card>
<NewSalaryRunDialog
open={showNewRun}
onOpenChange={(open) => {
if (!open) closeNewRun()
}}
/>
</div>
)
}
@@ -2,6 +2,7 @@
import { use, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { ArrowLeft, Calculator, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -10,44 +11,45 @@ import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types'
const LINE_ITEM_TYPE_LABELS: Record<SalaryLineItemType, string> = {
monthly_salary: 'Månadslön',
hourly_salary: 'Timlön',
overtime: 'Övertid',
overtime_50: 'Övertid 50 %',
overtime_100: 'Övertid 100 %',
ob_weekday_evening: 'OB vardag kväll',
ob_weekend: 'OB helg',
ob_night: 'OB natt',
ob_holiday: 'OB helgdag',
bonus: 'Bonus',
commission: 'Provision',
gross_deduction_pension: 'Bruttoavdrag: pension',
gross_deduction_other: 'Bruttoavdrag: övrigt',
benefit_car: 'Bilförmån',
benefit_housing: 'Bostadsförmån',
benefit_meals: 'Kostförmån',
benefit_wellness: 'Friskvård',
benefit_bike: 'Cykelförmån',
benefit_other: 'Övrig förmån',
sick_karens: 'Karensavdrag',
sick_day2_14: 'Sjuklön (dag 2-14, 80 %)',
sick_day15_plus: 'Sjuklön (dag 15+, Försäkringskassan)',
vab: 'VAB (vård av sjukt barn)',
parental_leave: 'Föräldraledighet',
unpaid_leave: 'Tjänstledighet utan lön',
vacation: 'Semester',
semesterersattning: 'Semesterersättning',
traktamente_taxfree: 'Traktamente (skattefritt)',
traktamente_taxable: 'Traktamente (skattepliktigt)',
mileage_taxfree: 'Milersättning (skattefritt)',
mileage_taxable: 'Milersättning (skattepliktigt)',
net_deduction_advance: 'Nettoavdrag: förskott',
net_deduction_union: 'Nettoavdrag: fackavgift',
net_deduction_benefit_payment: 'Nettoavdrag: förmånsbetalning',
net_deduction_other: 'Nettoavdrag: övrigt',
correction: 'Korrigering',
other: 'Övrigt',
/** Translation keys in the `salary_run_employee` namespace. */
const LINE_ITEM_TYPE_KEYS: Record<SalaryLineItemType, string> = {
monthly_salary: 'li_monthly_salary',
hourly_salary: 'li_hourly_salary',
overtime: 'li_overtime',
overtime_50: 'li_overtime_50',
overtime_100: 'li_overtime_100',
ob_weekday_evening: 'li_ob_weekday_evening',
ob_weekend: 'li_ob_weekend',
ob_night: 'li_ob_night',
ob_holiday: 'li_ob_holiday',
bonus: 'li_bonus',
commission: 'li_commission',
gross_deduction_pension: 'li_gross_deduction_pension',
gross_deduction_other: 'li_gross_deduction_other',
benefit_car: 'li_benefit_car',
benefit_housing: 'li_benefit_housing',
benefit_meals: 'li_benefit_meals',
benefit_wellness: 'li_benefit_wellness',
benefit_bike: 'li_benefit_bike',
benefit_other: 'li_benefit_other',
sick_karens: 'li_sick_karens',
sick_day2_14: 'li_sick_day2_14',
sick_day15_plus: 'li_sick_day15_plus',
vab: 'li_vab',
parental_leave: 'li_parental_leave',
unpaid_leave: 'li_unpaid_leave',
vacation: 'li_vacation',
semesterersattning: 'li_semesterersattning',
traktamente_taxfree: 'li_traktamente_taxfree',
traktamente_taxable: 'li_traktamente_taxable',
mileage_taxfree: 'li_mileage_taxfree',
mileage_taxable: 'li_mileage_taxable',
net_deduction_advance: 'li_net_deduction_advance',
net_deduction_union: 'li_net_deduction_union',
net_deduction_benefit_payment: 'li_net_deduction_benefit_payment',
net_deduction_other: 'li_net_deduction_other',
correction: 'li_correction',
other: 'li_other',
}
interface DetailResponse {
@@ -60,6 +62,7 @@ export default function SalaryRunEmployeeDetailPage({
}: {
params: Promise<{ id: string; employeeId: string }>
}) {
const t = useTranslations('salary_run_employee')
const { id: runId, employeeId } = use(params)
const [data, setData] = useState<DetailResponse | null>(null)
const [loading, setLoading] = useState(true)
@@ -79,11 +82,11 @@ export default function SalaryRunEmployeeDetailPage({
])
const runJson = await runRes.json()
const sreJson = await sreRes.json()
if (!runRes.ok) throw new Error(runJson.error || 'Kunde inte ladda lönekörning')
if (!sreRes.ok) throw new Error(sreJson.error || 'Kunde inte ladda anställd')
if (!runRes.ok) throw new Error(runJson.error || t('error_load_run'))
if (!sreRes.ok) throw new Error(sreJson.error || t('error_load_employee'))
setData({ run: runJson.data, runEmployee: sreJson.data })
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
setError(e instanceof Error ? e.message : t('unknown_error'))
} finally {
setLoading(false)
}
@@ -101,11 +104,11 @@ export default function SalaryRunEmployeeDetailPage({
const res = await fetch(`/api/salary/runs/${runId}/calculate`, { method: 'POST' })
const json = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(json.error || 'Beräkning misslyckades')
throw new Error(json.error || t('error_calculate'))
}
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
setError(e instanceof Error ? e.message : t('unknown_error'))
} finally {
setCalculating(false)
}
@@ -129,7 +132,7 @@ export default function SalaryRunEmployeeDetailPage({
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Laddar...
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('loading')}
</div>
)
}
@@ -141,10 +144,10 @@ export default function SalaryRunEmployeeDetailPage({
href={`/salary/runs/${runId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:underline"
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> Tillbaka till lönekörning
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> {t('back_to_run')}
</Link>
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error ?? 'Kunde inte ladda anställd'}
{error ?? t('error_load_employee')}
</div>
</div>
)
@@ -164,7 +167,7 @@ export default function SalaryRunEmployeeDetailPage({
href={`/salary/runs/${runId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:underline"
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> Tillbaka till lönekörning
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> {t('back_to_run')}
</Link>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
@@ -172,7 +175,7 @@ export default function SalaryRunEmployeeDetailPage({
{employee.first_name} {employee.last_name}
</h1>
<p className="text-sm text-muted-foreground tabular-nums">
{employee.personnummer} · Lönespecifikation {periodLabel}
{employee.personnummer} · {t('payslip_period', { period: periodLabel })}
</p>
</div>
{run.status === 'draft' && (
@@ -187,7 +190,7 @@ export default function SalaryRunEmployeeDetailPage({
) : (
<Calculator className="mr-1.5 h-3.5 w-3.5" />
)}
Beräkna
{t('calculate')}
</Button>
)}
</div>
@@ -195,20 +198,20 @@ export default function SalaryRunEmployeeDetailPage({
{/* Summary */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<SummaryCard label="Brutto" value={runEmployee.gross_salary} />
<SummaryCard label={t('gross')} value={runEmployee.gross_salary} />
<SummaryCard
label="Skatt"
label={t('tax')}
value={runEmployee.tax_withheld_override ?? runEmployee.tax_withheld}
overridden={runEmployee.tax_withheld_override !== null}
/>
<SummaryCard
label="Netto"
label={t('net')}
value={runEmployee.net_salary + (runEmployee.tax_withheld - (runEmployee.tax_withheld_override ?? runEmployee.tax_withheld))}
accent
overridden={runEmployee.tax_withheld_override !== null}
/>
<SummaryCard
label="Avgifter"
label={t('avgifter')}
value={runEmployee.avgifter_amount_override ?? runEmployee.avgifter_amount}
overridden={runEmployee.avgifter_amount_override !== null}
/>
@@ -234,11 +237,11 @@ export default function SalaryRunEmployeeDetailPage({
{/* Unified calendar: worked time (for hourly) + absence on the same grid */}
<Card>
<CardHeader>
<CardTitle className="text-base">Tid och frånvaro</CardTitle>
<CardTitle className="text-base">{t('time_absence_title')}</CardTitle>
<p className="text-xs text-muted-foreground">
{employee.salary_type === 'hourly'
? 'Markera dagar och ange arbetade timmar eller frånvaro. Grundlönen räknas som timlön × summa arbetade timmar. Karensavdrag, sjuklön och AGI-rapportering härleds automatiskt.'
: 'Markera sjukdom, VAB, föräldraledighet och annan frånvaro per dag. Karensavdrag, sjuklön och AGI-rapportering räknas ut automatiskt.'}
? t('calendar_hint_hourly')
: t('calendar_hint_monthly')}
</p>
</CardHeader>
<CardContent>
@@ -253,9 +256,9 @@ export default function SalaryRunEmployeeDetailPage({
onAbsenceCountsChange={setLiveCounts}
/>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs">
<AbsenceCount label="Sjukdagar" days={liveCounts?.sick ?? runEmployee.sick_days} />
<AbsenceCount label="VAB-dagar" days={liveCounts?.vab ?? runEmployee.vab_days} />
<AbsenceCount label="Föräldraledig" days={liveCounts?.parental ?? runEmployee.parental_days} />
<AbsenceCount label={t('sick_days')} days={liveCounts?.sick ?? runEmployee.sick_days} />
<AbsenceCount label={t('vab_days')} days={liveCounts?.vab ?? runEmployee.vab_days} />
<AbsenceCount label={t('parental_days')} days={liveCounts?.parental ?? runEmployee.parental_days} />
</div>
</CardContent>
</Card>
@@ -263,27 +266,27 @@ export default function SalaryRunEmployeeDetailPage({
{/* Line items */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lönerader ({lineItems.length})</CardTitle>
<CardTitle className="text-base">{t('line_items_title', { count: lineItems.length })}</CardTitle>
</CardHeader>
<CardContent className="p-0">
{lineItems.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
Inga lönerader. Kör beräkning lönekörningen för att skapa standardrader.
{t('no_line_items')}
</p>
) : (
<table className="w-full">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="px-4 py-2">Typ</th>
<th className="px-4 py-2">Beskrivning</th>
<th className="px-4 py-2 text-right">Antal</th>
<th className="px-4 py-2 text-right">Belopp</th>
<th className="px-4 py-2">{t('th_type')}</th>
<th className="px-4 py-2">{t('th_description')}</th>
<th className="px-4 py-2 text-right">{t('th_quantity')}</th>
<th className="px-4 py-2 text-right">{t('th_amount')}</th>
</tr>
</thead>
<tbody>
{lineItems.map(li => (
<tr key={li.id} className="border-b last:border-0">
<td className="px-4 py-2 text-xs text-muted-foreground">{LINE_ITEM_TYPE_LABELS[li.item_type] ?? li.item_type}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{LINE_ITEM_TYPE_KEYS[li.item_type] ? t(LINE_ITEM_TYPE_KEYS[li.item_type]) : li.item_type}</td>
<td className="px-4 py-2 text-sm">{li.description}</td>
<td className="px-4 py-2 text-sm text-right tabular-nums">{li.quantity ?? '-'}</td>
<td className="px-4 py-2 text-sm text-right tabular-nums">{formatCurrency(li.amount)}</td>
@@ -299,11 +302,12 @@ export default function SalaryRunEmployeeDetailPage({
}
function SummaryCard({ label, value, accent, overridden }: { label: string; value: number; accent?: boolean; overridden?: boolean }) {
const t = useTranslations('salary_run_employee')
return (
<div className={`rounded-md border bg-card p-3 ${accent ? 'ring-1 ring-primary/40' : ''} ${overridden ? 'ring-1 ring-warning/40' : ''}`}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{label}
{overridden && <span className="text-[10px] uppercase tracking-wider text-warning">Justerat</span>}
{overridden && <span className="text-[10px] uppercase tracking-wider text-warning">{t('adjusted_badge')}</span>}
</div>
<div className="mt-0.5 text-lg font-medium tabular-nums">{formatCurrency(value)}</div>
</div>
@@ -311,10 +315,11 @@ function SummaryCard({ label, value, accent, overridden }: { label: string; valu
}
function AbsenceCount({ label, days }: { label: string; days: number }) {
const t = useTranslations('salary_run_employee')
return (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="text-sm font-medium tabular-nums">{days} dagar</div>
<div className="text-sm font-medium tabular-nums">{t('days_count', { days })}</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
import { redirect } from 'next/navigation'
// Salary run creation now happens in a modal on the salary overview (matching
// the verifikat pattern): the form itself lives in
// components/salary/NewSalaryRunDialog.tsx. This route survives as a redirect
// so old links, bookmarks, and agent intents keep working.
export default function NewSalaryRunPage() {
redirect('/salary?new=1')
}
+4 -2
View File
@@ -55,10 +55,12 @@ export default function SupplierDetailPage() {
}
async function fetchInvoices() {
const res = await fetch(`/api/supplier-invoices?status=all`)
const res = await fetch(
`/api/supplier-invoices?status=all&supplier_id=${encodeURIComponent(String(params.id))}`,
)
const { data } = await res.json()
if (data) {
setInvoices(data.filter((inv: SupplierInvoice) => inv.supplier_id === params.id))
setInvoices(data as SupplierInvoice[])
}
}
+42 -39
View File
@@ -21,16 +21,45 @@ export default async function AgentOnboardingPage() {
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
// Everything that doesn't depend on the TIC snapshot loads in one batch:
// the settings row (carrying the is_sandbox gate + the onboarding-form
// data, moms_period, fiscal_year_start_month, f_skatt, city, …, that
// never makes it onto `companies` proper), the greeting profile, any
// existing agent profile, and the atom registry titles ("Konsult It"-style
// slug labels look ugly; the registry has them as authored).
const [
{ data: settings },
{ data: profile },
{ data: existingProfile },
{ data: atomRows },
hdrs,
] = await Promise.all([
supabase
.from('company_settings')
.select(
'is_sandbox, city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employee_count, has_employees',
)
.eq('company_id', companyId)
.maybeSingle(),
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase
.from('agent_profiles')
.select('company_id, profile_summary, verified_at')
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('agent_atom_registry')
.select('id, title')
.eq('is_active', true)
.is('parent_atom_id', null), // skill titles only; reference children never appear as profile chips
headers(),
])
// Sandbox companies ship with a pre-built verified agent_profile: the
// build flow on this page would call TIC and the gated composer stream,
// both of which 403. Send them back to the dashboard where the demo
// assistant is already visible via the sheet preview.
const { data: settingsForSandbox } = await supabase
.from('company_settings')
.select('is_sandbox')
.eq('company_id', companyId)
.maybeSingle()
if (settingsForSandbox?.is_sandbox) redirect('/')
if (settings?.is_sandbox) redirect('/')
// Trigger the TIC live-fetch + cache before the field-resolving query
// below. ensureTicSnapshot is fast on cache-hit (single SELECT) and
@@ -38,7 +67,6 @@ export default async function AgentOnboardingPage() {
// streaming endpoint; this just lets the initial Phase B render show the
// SNI/verksamhetsbeskrivning when the user returns to the page after
// stream completion.
const hdrs = await headers()
const cookieHeader = hdrs.get('cookie') ?? ''
const host = hdrs.get('host') ?? 'localhost:3000'
const proto = hdrs.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https')
@@ -51,30 +79,13 @@ export default async function AgentOnboardingPage() {
// Fetch the small handful of fields we render directly into Phase B so the
// user sees real values (not "Laddar…") the moment the stream finishes.
// company_settings is a separate fetch because it carries the onboarding-
// form data (moms_period, fiscal_year_start_month, f_skatt, city, …) that
// never makes it onto `companies` proper.
const [{ data: company }, { data: profile }, { data: existingProfile }, { data: settings }] =
await Promise.all([
supabase
.from('companies')
.select('name, entity_type, org_number, tic_snapshot')
.eq('id', companyId)
.single(),
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase
.from('agent_profiles')
.select('company_id, profile_summary, verified_at')
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('company_settings')
.select(
'city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employee_count, has_employees',
)
.eq('company_id', companyId)
.maybeSingle(),
])
// Must run AFTER ensureTicSnapshot, it reads the tic_snapshot that call
// may have just written.
const { data: company } = await supabase
.from('companies')
.select('name, entity_type, org_number, tic_snapshot')
.eq('id', companyId)
.single()
if (!company) redirect('/onboarding')
@@ -83,14 +94,6 @@ export default async function AgentOnboardingPage() {
// before the stream completes so the layout doesn't jump.
const initialFields = buildInitialFields(company, settings)
// Atom titles: slug-derived labels look ugly ("Konsult It",
// "Single Shareholder Ab Fmb"). Fetch the registry titles once and pass them
// to the review card so chips render as authored.
const { data: atomRows } = await supabase
.from('agent_atom_registry')
.select('id, title')
.eq('is_active', true)
.is('parent_atom_id', null) // skill titles only; reference children never appear as profile chips
const atomTitles: Record<string, string> = {}
for (const row of (atomRows ?? []) as { id: string; title: string }[]) {
atomTitles[row.id] = row.title
+40 -37
View File
@@ -18,21 +18,46 @@ export default async function SelectCompanyPage() {
redirect('/login')
}
// Existing Accounted memberships.
const { data: memberships } = await supabase
.from('company_members')
.select(`
role,
company:company_id (
id,
name,
org_number,
entity_type,
archived_at
)
`)
.eq('user_id', user.id)
.order('joined_at', { ascending: true })
// All four lookups key only on user.id, one parallel batch instead of
// four serial round-trips on the post-BankID-login landing page.
const [
// Existing Accounted memberships.
{ data: memberships },
{ data: teamMembership },
// Greeting name.
{ data: profile },
// BankID enrichment (CompanyRoles from Bolagsverket via TIC). Stored
// user-keyed in `bankid_enrichment` because it lands before company
// selection, see fetchAndStoreEnrichment in the tic extension.
{ data: enrichmentRow },
] = await Promise.all([
supabase
.from('company_members')
.select(`
role,
company:company_id (
id,
name,
org_number,
entity_type,
archived_at
)
`)
.eq('user_id', user.id)
.order('joined_at', { ascending: true }),
supabase
.from('team_members')
.select('team_id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle(),
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase
.from('bankid_enrichment')
.select('company_roles, created_at, updated_at')
.eq('user_id', user.id)
.maybeSingle(),
])
type CompanyRow = {
id: string
@@ -68,13 +93,6 @@ export default async function SelectCompanyPage() {
)
// Ensure the user has a team (same pattern as /onboarding).
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
let teamId = teamMembership?.team_id
if (!teamId) {
const { data: ensured } = await supabase.rpc('ensure_user_team')
@@ -84,23 +102,8 @@ export default async function SelectCompanyPage() {
redirect('/login')
}
// Greeting name.
const { data: profile } = await supabase
.from('profiles')
.select('full_name')
.eq('id', user.id)
.single()
const firstName = profile?.full_name?.split(' ')[0] ?? null
// BankID enrichment (CompanyRoles from Bolagsverket via TIC). Stored
// user-keyed in `bankid_enrichment` because it lands before company
// selection: see fetchAndStoreEnrichment in the tic extension.
const { data: enrichmentRow } = await supabase
.from('bankid_enrichment')
.select('company_roles, created_at, updated_at')
.eq('user_id', user.id)
.maybeSingle()
const enrichmentValue = enrichmentRow
? { companyRoles: enrichmentRow.company_roles as EnrichmentCompanyRole[] }
: null
+11 -10
View File
@@ -1,7 +1,8 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { requireAuth } from '@/lib/auth/require-auth'
import { validateBody } from '@/lib/api/validate'
import { eventBus } from '@/lib/events'
import { createLogger } from '@/lib/logger'
@@ -26,14 +27,17 @@ const DeleteAccountSchema = z.object({
* Precondition: the user must own zero non-archived companies. The RPC
* enforces this at the DB level and raises SQLSTATE P0001 with a message
* if the precondition fails: we return 409 in that case.
*
* Not wrapped in withRouteContext: deletion must work for users with zero
* companies, so there is no company context to resolve. requireAuth() is
* used directly so MFA (AAL2) is still enforced on hosted: a stolen AAL1
* cookie must not be able to destroy the account. BankID-linked users are
* exempt from the AAL2 gate (BankID is inherently 2FA, see shouldEnforceMfa).
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const auth = await requireAuth()
if (auth.error) return auth.error
const { user, supabase } = auth
const result = await validateBody(request, DeleteAccountSchema)
if (!result.success) return result.response
@@ -130,8 +134,5 @@ export async function POST(request: Request) {
// Best-effort: clear the caller's session cookie too.
await supabase.auth.signOut().catch(() => {})
// Request body is consumed; avoid unused-var lint.
void request
return NextResponse.json({ success: true })
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Tests for POST /api/agent/composer.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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 checkRateMock = vi.fn()
vi.mock('@/lib/rate-limits/agent', () => ({
checkAgentRateLimit: (...args: unknown[]) => checkRateMock(...args),
agentRateLimitResponseBody: () => ({ error: 'För många förfrågningar.' }),
}))
vi.mock('@/lib/sandbox/guard', () => ({
guardSandbox: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/entitlements/keys', () => ({
CAPABILITY: { ai: 'ai' },
}))
const composeMock = vi.fn()
vi.mock('@/lib/agent/composer', () => ({
composeAgentProfile: (...args: unknown[]) => composeMock(...args),
}))
import { POST } from '../composer/route'
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
checkRateMock.mockResolvedValue({ ok: true })
})
describe('POST /api/agent/composer', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = createMockRequest('/api/agent/composer', { method: 'POST', body: {} })
const res = await POST(req, routeParams)
expect(res.status).toBe(401)
})
it('returns 429 when rate limited', async () => {
checkRateMock.mockResolvedValue({ ok: false, retryAfterSec: 30 })
const req = createMockRequest('/api/agent/composer', { method: 'POST', body: {} })
const res = await POST(req, routeParams)
expect(res.status).toBe(429)
expect(res.headers.get('Retry-After')).toBe('30')
})
it('refuses a viewer with 403 (composer rewrites the profile)', async () => {
enqueue({ data: { role: 'viewer' } })
const req = createMockRequest('/api/agent/composer', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await POST(req, routeParams)
)
expect(status).toBe(403)
expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED')
expect(composeMock).not.toHaveBeenCalled()
})
it('runs the composer for a non-viewer member', async () => {
enqueue({ data: { role: 'owner' } })
composeMock.mockResolvedValue({ company_id: 'company-1', profile_summary: 'Byggd' })
const req = createMockRequest('/api/agent/composer', {
method: 'POST',
body: { dry_run: true },
})
const { status, body } = await parseJsonResponse<{ data: { profile_summary: string } }>(
await POST(req, routeParams)
)
expect(status).toBe(200)
expect(body.data.profile_summary).toBe('Byggd')
expect(composeMock).toHaveBeenCalledWith(expect.anything(), 'company-1', { dryRun: true })
})
})
@@ -0,0 +1,176 @@
/**
* Tests for GET /api/agent/conversations and GET/PATCH /api/agent/conversations/[id].
*
* Uses a filter-capturing Supabase mock so the user-scoping regression is
* locked in: RLS on agent_conversations is company-scoped, so the explicit
* .eq('user_id', …) filter in the list route is the only thing preventing
* team members from seeing each other's conversation titles/previews.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
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'),
}))
import { GET as listGET } from '../conversations/route'
import { GET as detailGET, PATCH as detailPATCH } from '../conversations/[id]/route'
interface CapturedCall {
method: string
args: unknown[]
}
/** Chainable builder that records every call and resolves queued results per from(). */
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
const calls: CapturedCall[] = []
let idx = 0
const makeBuilder = () => {
const result = results[idx++] ?? { data: null, error: null }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: any = {}
for (const m of ['select', 'eq', 'is', 'or', 'order', 'limit', 'insert', 'update', 'maybeSingle', 'single']) {
b[m] = (...args: unknown[]) => {
calls.push({ method: m, args })
return b
}
}
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null, count: null })
return b
}
const supabase = {
from: (table: string) => {
calls.push({ method: 'from', args: [table] })
return makeBuilder()
},
}
return { supabase, calls }
}
const routeParams = { params: Promise.resolve({}) }
const idParams = { params: Promise.resolve({ id: 'conv-1' }) }
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/agent/conversations', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await listGET(createMockRequest('/api/agent/conversations'), routeParams)
expect(res.status).toBe(401)
})
it('returns 400 for a non-numeric limit', async () => {
const { supabase } = createCapturingSupabase([])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const req = createMockRequest('/api/agent/conversations', { searchParams: { limit: 'abc' } })
const { status } = await parseJsonResponse(await listGET(req, routeParams))
expect(status).toBe(400)
})
it('filters the list by BOTH company_id and the calling user_id', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: [{ id: 'conv-1', title: 'Min konversation' }] },
])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const req = createMockRequest('/api/agent/conversations')
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await listGET(req, routeParams)
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
// Privacy regression guard: without this filter, company-scoped RLS lets
// every member read colleagues' titles and last_message_preview.
expect(eqCalls).toContainEqual(['user_id', 'user-1'])
})
})
describe('GET /api/agent/conversations/[id]', () => {
it('returns 404 when the conversation is not owned by the caller', async () => {
// Ownership is part of the fetch (.eq user_id) — a non-owned id resolves null.
const { supabase, calls } = createCapturingSupabase([{ data: null }])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await detailGET(createMockRequest('/api/agent/conversations/conv-1'), idParams)
)
expect(status).toBe(404)
expect(body.error.code).toBe('CONVERSATION_NOT_FOUND')
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
expect(eqCalls).toContainEqual(['user_id', 'user-1'])
})
it('returns the conversation with its messages for the owner', async () => {
const { supabase } = createCapturingSupabase([
{ data: { id: 'conv-1', company_id: 'company-1', user_id: 'user-1', title: 'T' } },
{ data: { role: 'member' } },
{ data: [{ id: 'm1', role: 'user', content: 'Hej' }] },
])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const { status, body } = await parseJsonResponse<{
data: { conversation: { id: string }; messages: unknown[] }
}>(await detailGET(createMockRequest('/api/agent/conversations/conv-1'), idParams))
expect(status).toBe(200)
expect(body.data.conversation.id).toBe('conv-1')
expect(body.data.messages).toHaveLength(1)
})
})
describe('PATCH /api/agent/conversations/[id]', () => {
it('returns 400 when the body has nothing to update', async () => {
const { supabase } = createCapturingSupabase([])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const req = createMockRequest('/api/agent/conversations/conv-1', {
method: 'PATCH',
body: {},
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await detailPATCH(req, idParams)
)
expect(status).toBe(400)
expect(body.error.code).toBe('NOTHING_TO_UPDATE')
})
it('updates pin state for an owned conversation', async () => {
const { supabase } = createCapturingSupabase([
{ data: { user_id: 'user-1', company_id: 'company-1' } },
{ data: { id: 'conv-1', pinned: true } },
])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const req = createMockRequest('/api/agent/conversations/conv-1', {
method: 'PATCH',
body: { pinned: true },
})
const { status, body } = await parseJsonResponse<{ data: { pinned: boolean } }>(
await detailPATCH(req, idParams)
)
expect(status).toBe(200)
expect(body.data.pinned).toBe(true)
})
})
+150
View File
@@ -0,0 +1,150 @@
/**
* Tests for GET/POST /api/agent/memory and PATCH /api/agent/memory/[id].
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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),
}))
import { GET, POST } from '../memory/route'
import { PATCH } from '../memory/[id]/route'
const routeParams = { params: Promise.resolve({}) }
const idParams = { params: Promise.resolve({ id: 'mem-1' }) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('GET /api/agent/memory', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(createMockRequest('/api/agent/memory'), routeParams)
expect(res.status).toBe(401)
})
it('returns 400 for an unknown kind filter', async () => {
const req = createMockRequest('/api/agent/memory', { searchParams: { kind: 'gossip' } })
const { status } = await parseJsonResponse(await GET(req, routeParams))
expect(status).toBe(400)
})
it('lists memory entries', async () => {
enqueue({ data: [{ id: 'mem-1', kind: 'fact', content: 'Fakturerar i SEK' }] })
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await GET(createMockRequest('/api/agent/memory'), routeParams)
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
})
})
describe('POST /api/agent/memory', () => {
it('rejects a viewer in the target company with 403', async () => {
// requireWrite passed for the ACTIVE company, but the body targets a
// company where the caller is only a viewer — the re-check must refuse.
enqueue({ data: { role: 'viewer' } })
const req = createMockRequest('/api/agent/memory', {
method: 'POST',
body: { company_id: '7f3e0b1a-9c4d-4a2b-8f6e-1d2c3b4a5e6f', content: 'Ett minne' },
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await POST(req, routeParams)
)
expect(status).toBe(403)
expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED')
})
it('rejects an invalid body with 400', async () => {
const req = createMockRequest('/api/agent/memory', {
method: 'POST',
body: { content: 'x' }, // below min length 2
})
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(400)
})
it('inserts a memory entry for the active company', async () => {
enqueue({ data: { role: 'admin' } }) // membership re-check
enqueue({ data: { id: 'mem-2', kind: 'fact', content: 'Ett minne' } })
const req = createMockRequest('/api/agent/memory', {
method: 'POST',
body: { content: 'Ett minne' },
})
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(
await POST(req, routeParams)
)
expect(status).toBe(200)
expect(body.data.id).toBe('mem-2')
})
})
describe('PATCH /api/agent/memory/[id]', () => {
it('returns 400 when the body has nothing to update', async () => {
const req = createMockRequest('/api/agent/memory/mem-1', { method: 'PATCH', body: {} })
const { status } = await parseJsonResponse(await PATCH(req, idParams))
expect(status).toBe(400)
})
it('returns 404 when the memory row does not exist', async () => {
enqueue({ data: null })
const req = createMockRequest('/api/agent/memory/mem-1', {
method: 'PATCH',
body: { is_pinned: true },
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await PATCH(req, idParams)
)
expect(status).toBe(404)
expect(body.error.code).toBe('MEMORY_NOT_FOUND')
})
it('updates a memory entry', async () => {
enqueue({ data: { company_id: 'company-1' } }) // row lookup
enqueue({ data: { role: 'member' } }) // membership re-check
enqueue({ data: { id: 'mem-1', is_pinned: true } }) // update
const req = createMockRequest('/api/agent/memory/mem-1', {
method: 'PATCH',
body: { is_pinned: true },
})
const { status, body } = await parseJsonResponse<{ data: { is_pinned: boolean } }>(
await PATCH(req, idParams)
)
expect(status).toBe(200)
expect(body.data.is_pinned).toBe(true)
})
})
@@ -0,0 +1,92 @@
/**
* Tests for POST /api/agent/onboarding/stream — auth surface only.
*
* The composer pipeline itself is exercised via lib tests; here we lock in
* the guard order: 401, membership 403, and the viewer refusal (the pipeline
* upserts agent_profiles, so viewers must not be able to trigger it).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/rate-limits/agent', () => ({
checkAgentRateLimit: vi.fn().mockResolvedValue({ ok: true }),
agentRateLimitResponseBody: () => ({ error: 'För många förfrågningar.' }),
}))
vi.mock('@/lib/sandbox/guard', () => ({ guardSandbox: vi.fn().mockResolvedValue(null) }))
vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/entitlements/keys', () => ({ CAPABILITY: { ai: 'ai' } }))
// Pipeline internals — never reached in these tests, stubbed so the module loads.
vi.mock('@/lib/agent/composer/inputs', () => ({
gatherComposerInputs: vi.fn(),
inputsToSourceSignals: vi.fn(),
}))
vi.mock('@/lib/agent/composer/atom-selection', () => ({
selectAtoms: vi.fn(),
filterRedundantQuestions: vi.fn(),
}))
vi.mock('@/lib/agent/composer/narrative', () => ({ writeNarrative: vi.fn() }))
vi.mock('@/lib/agent/composer/fallback', () => ({
fallbackAtomSelection: vi.fn(),
fallbackNarrative: vi.fn(),
}))
vi.mock('@/lib/agent/composer/prewarm', () => ({ preWarmAtomCache: vi.fn() }))
vi.mock('@/lib/agent/composer/client', () => ({ OPUS_MODEL: 'opus-test' }))
vi.mock('@/lib/agent/composer/tic-fetch', () => ({ ensureTicSnapshot: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() }))
import { createClient } from '@/lib/supabase/server'
import { POST } from '../onboarding/stream/route'
const mockCreateClient = vi.mocked(createClient)
function mockAuth(userId: string | null, membership: { role: string } | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: userId ? { id: userId } : null } }),
},
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: membership, error: null }),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('POST /api/agent/onboarding/stream', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null, null)
const req = createMockRequest('/api/agent/onboarding/stream', { method: 'POST', body: {} })
const res = await POST(req)
expect(res.status).toBe(401)
})
it('returns 403 for a non-member', async () => {
mockAuth('user-1', null)
const req = createMockRequest('/api/agent/onboarding/stream', { method: 'POST', body: {} })
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(403)
})
it('refuses a viewer with 403 (pipeline upserts agent_profiles)', async () => {
mockAuth('user-1', { role: 'viewer' })
const req = createMockRequest('/api/agent/onboarding/stream', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ error: string }>(await POST(req))
expect(status).toBe(403)
expect(body.error).toContain('läsbehörighet')
})
})
+149
View File
@@ -0,0 +1,149 @@
/**
* Tests for GET/PATCH /api/agent/profile and POST /api/agent/profile/verify.
*
* Covers the role model: reads allow any member, mutations (PATCH, verify)
* refuse viewers — the same rule verify always had, now enforced on PATCH too.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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'),
}))
import { GET, PATCH } from '../profile/route'
import { POST as VERIFY } from '../profile/verify/route'
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
})
describe('GET /api/agent/profile', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(createMockRequest('/api/agent/profile'), routeParams)
expect(res.status).toBe(401)
})
it('returns 403 when the caller is not a member of the target company', async () => {
enqueue({ data: null }) // membership lookup
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(createMockRequest('/api/agent/profile'), routeParams)
)
expect(status).toBe(403)
expect(body.error.code).toBe('NOT_COMPANY_MEMBER')
})
it('returns the profile for a member (viewers may read)', async () => {
enqueue({ data: { role: 'viewer' } })
enqueue({ data: { company_id: 'company-1', profile_summary: 'Konsultbolag' } })
const { status, body } = await parseJsonResponse<{ data: { profile_summary: string } }>(
await GET(createMockRequest('/api/agent/profile'), routeParams)
)
expect(status).toBe(200)
expect(body.data.profile_summary).toBe('Konsultbolag')
})
})
describe('PATCH /api/agent/profile', () => {
it('refuses a viewer with 403 (profile mutation)', async () => {
enqueue({ data: { role: 'viewer' } })
const req = createMockRequest('/api/agent/profile', {
method: 'PATCH',
body: { profile_summary: 'Nytt sammandrag' },
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await PATCH(req, routeParams)
)
expect(status).toBe(403)
expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED')
})
it('returns 404 when the company has no agent_profile row', async () => {
enqueue({ data: { role: 'admin' } })
enqueue({ data: null }) // current profile lookup
const req = createMockRequest('/api/agent/profile', {
method: 'PATCH',
body: { profile_summary: 'Nytt sammandrag' },
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await PATCH(req, routeParams)
)
expect(status).toBe(404)
expect(body.error.code).toBe('AGENT_PROFILE_NOT_FOUND')
})
it('returns 400 when the body contains nothing to update', async () => {
enqueue({ data: { role: 'admin' } })
enqueue({ data: { field_overrides: null } })
const req = createMockRequest('/api/agent/profile', { method: 'PATCH', body: {} })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await PATCH(req, routeParams)
)
expect(status).toBe(400)
expect(body.error.code).toBe('NOTHING_TO_UPDATE')
})
it('merges field_overrides and updates the profile', async () => {
enqueue({ data: { role: 'admin' } })
enqueue({ data: { field_overrides: { old: { value: 1, overridden_at: 'x' } } } })
enqueue({ data: { company_id: 'company-1', profile_summary: 'Uppdaterad' } })
const req = createMockRequest('/api/agent/profile', {
method: 'PATCH',
body: { profile_summary: 'Uppdaterad', field_overrides: { vat_period: 'quarterly' } },
})
const { status, body } = await parseJsonResponse<{ data: { profile_summary: string } }>(
await PATCH(req, routeParams)
)
expect(status).toBe(200)
expect(body.data.profile_summary).toBe('Uppdaterad')
})
})
describe('POST /api/agent/profile/verify', () => {
it('refuses a viewer with 403', async () => {
enqueue({ data: { role: 'viewer' } })
const req = createMockRequest('/api/agent/profile/verify', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await VERIFY(req, routeParams)
)
expect(status).toBe(403)
expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED')
})
it('stamps verified_at for a non-viewer member', async () => {
enqueue({ data: { role: 'owner' } })
enqueue({ data: { company_id: 'company-1', verified_at: '2026-07-03T00:00:00Z', verified_by_user_id: 'user-1' } })
const req = createMockRequest('/api/agent/profile/verify', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ data: { verified_by_user_id: string } }>(
await VERIFY(req, routeParams)
)
expect(status).toBe(200)
expect(body.data.verified_by_user_id).toBe('user-1')
})
})
+47 -26
View File
@@ -1,7 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
import { composeAgentProfile } from '@/lib/agent/composer'
import { guardSandbox } from '@/lib/sandbox/guard'
@@ -24,13 +23,12 @@ const BodySchema = z.object({
// 4. Persists to agent_profiles (skipped on dry_run).
// 5. Fires fire-and-forget cache pre-warm.
//
// Auth: must be a member of the target company.
// Auth: must be a non-viewer member of the target company (it rewrites the
// company's agent_profile unless dry_run).
//
// Plan ref: dev_docs/specialized-agent-plan.md §6.
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const POST = withRouteContext('agent.composer.run', async (request, ctx) => {
const { supabase, companyId: activeCompanyId, user } = ctx
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) {
@@ -40,22 +38,29 @@ export async function POST(request: Request) {
})
}
let body: z.infer<typeof BodySchema>
try {
body = BodySchema.parse(await request.json().catch(() => ({})))
} catch (err) {
// Tolerant parse: ops callers POST with an empty body, which is valid here.
const raw = await request.json().catch(() => ({}))
const parsed = BodySchema.safeParse(raw)
if (!parsed.success) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{
error: 'Validation failed',
type: 'validation_error',
errors: parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
},
{ status: 400 },
)
}
const body = parsed.data
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) {
return NextResponse.json({ error: 'No active company' }, { status: 400 })
}
const companyId = body.company_id ?? activeCompanyId
// Defense in depth alongside RLS: confirm membership before composing.
// Defense in depth alongside RLS: confirm membership before composing, and
// require a non-viewer role: the composer rewrites agent_profiles.
const { data: membership } = await supabase
.from('company_members')
.select('role')
@@ -63,7 +68,28 @@ export async function POST(request: Request) {
.eq('user_id', user.id)
.maybeSingle()
if (!membership) {
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
return NextResponse.json(
{
error: {
code: 'NOT_COMPANY_MEMBER',
message: 'Du är inte medlem i detta företag.',
message_en: 'Not a member of this company.',
},
},
{ status: 403 },
)
}
if (membership.role === 'viewer') {
return NextResponse.json(
{
error: {
code: 'WRITE_PERMISSION_REQUIRED',
message: 'Du har endast läsbehörighet i detta företag.',
message_en: 'You only have read access in this company.',
},
},
{ status: 403 },
)
}
const blocked = await guardSandbox(supabase, companyId)
@@ -72,11 +98,6 @@ export async function POST(request: Request) {
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai)
if (capBlocked) return capBlocked
try {
const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run })
return NextResponse.json({ data: composed })
} catch (err) {
const message = err instanceof Error ? err.message : 'Composer failed'
return NextResponse.json({ error: message }, { status: 500 })
}
}
const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run })
return NextResponse.json({ data: composed })
})
+97 -86
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
// GET /api/agent/conversations/[id]
//
@@ -18,98 +19,108 @@ const PatchSchema = z.object({
title: z.string().min(1).max(200).nullable().optional(),
})
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const notFound = () =>
NextResponse.json(
{
error: {
code: 'CONVERSATION_NOT_FOUND',
message: 'Konversationen hittades inte.',
message_en: 'Conversation not found.',
},
},
{ status: 404 },
)
const { id } = await params
export const GET = withRouteContext(
'agent.conversations.get',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, user } = ctx
const { data: conv, error: convErr } = await supabase
.from('agent_conversations')
.select(
'id, company_id, user_id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at',
)
.eq('id', id)
.maybeSingle()
if (convErr) return NextResponse.json({ error: convErr.message }, { status: 500 })
if (!conv) return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
// Conversations are user-scoped: fetched by ownership rather than the
// active company, so a user can open their own conversations in any
// company they belong to.
const { data: conv, error: convErr } = await supabase
.from('agent_conversations')
.select(
'id, company_id, user_id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at',
)
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (convErr) throw convErr
if (!conv) return notFound()
// Defense in depth alongside RLS: verify caller is a member of the
// conversation's company AND owns the conversation row. Conversations are
// user-scoped within a company; one team member should not see another's.
if (conv.user_id !== user.id) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
}
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', conv.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
}
// Defense in depth alongside RLS: the caller must still be a member of
// the conversation's company (they may have been removed since).
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', conv.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return notFound()
const { data: messages, error: msgErr } = await supabase
.from('agent_messages')
.select('id, role, content, tool_use_id, hidden, created_at')
.eq('conversation_id', id)
.order('created_at', { ascending: true })
if (msgErr) return NextResponse.json({ error: msgErr.message }, { status: 500 })
const { data: messages, error: msgErr } = await supabase
.from('agent_messages')
.select('id, role, content, tool_use_id, hidden, created_at')
.eq('conversation_id', id)
.order('created_at', { ascending: true })
if (msgErr) throw msgErr
return NextResponse.json({ data: { conversation: conv, messages: messages ?? [] } })
}
return NextResponse.json({ data: { conversation: conv, messages: messages ?? [] } })
},
)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const PATCH = withRouteContext(
'agent.conversations.update',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, user, log } = ctx
const { id } = await params
let body: z.infer<typeof PatchSchema>
try {
body = PatchSchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const validation = await validateBody(request, PatchSchema, {
log,
operation: 'agent.conversations.update',
})
if (!validation.success) return validation.response
const body = validation.data
const update: Record<string, unknown> = {}
if (body.pinned != null) update.pinned = body.pinned
if (body.archived != null) update.archived = body.archived
if (body.title != null) update.title = body.title
if (Object.keys(update).length === 0) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
const update: Record<string, unknown> = {}
if (body.pinned != null) update.pinned = body.pinned
if (body.archived != null) update.archived = body.archived
if (body.title != null) update.title = body.title
if (Object.keys(update).length === 0) {
return NextResponse.json(
{
error: {
code: 'NOTHING_TO_UPDATE',
message: 'Inget att uppdatera.',
message_en: 'Nothing to update.',
},
},
{ status: 400 },
)
}
// Defense in depth: verify ownership before update so a 404 is returned
// (instead of relying solely on RLS, which would silently 0-row).
const { data: existing } = await supabase
.from('agent_conversations')
.select('user_id, company_id')
.eq('id', id)
.maybeSingle()
if (!existing || existing.user_id !== user.id) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
}
// Defense in depth: verify ownership before update so a 404 is returned
// (instead of relying solely on RLS, which would silently 0-row).
const { data: existing } = await supabase
.from('agent_conversations')
.select('user_id, company_id')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (!existing) return notFound()
const { data, error } = await supabase
.from('agent_conversations')
.update(update)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', existing.company_id)
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at')
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data })
}
const { data, error } = await supabase
.from('agent_conversations')
.update(update)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', existing.company_id)
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at')
.single()
if (error) throw error
return NextResponse.json({ data })
},
)
+29 -18
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
// GET /api/agent/conversations
//
@@ -16,20 +17,25 @@ import { getActiveCompanyId } from '@/lib/company/context'
//
// Ordered: pinned first (within archived bucket), then last_message_at desc.
// Used by the /chat sidebar and "resume conversation" UI in the sheet.
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const ListQuerySchema = z.object({
archived: z.enum(['true', 'false']).default('false'),
pinned: z.enum(['true', 'false']).optional(),
intent: z.string().min(1).optional(),
q: z.string().optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
})
const url = new URL(request.url)
const archived = url.searchParams.get('archived') === 'true'
const pinnedOnly = url.searchParams.get('pinned') === 'true'
const intent = url.searchParams.get('intent') ?? null
const q = url.searchParams.get('q')?.trim() ?? ''
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 50, 1), 200)
export const GET = withRouteContext('agent.conversations.list', async (request, ctx) => {
const { supabase, companyId, user, log } = ctx
const validated = validateQuery(request, ListQuerySchema, {
log,
operation: 'agent.conversations.list',
})
if (!validated.success) return validated.response
const { archived, pinned, intent, limit } = validated.data
const q = validated.data.q?.trim() ?? ''
let query = supabase
.from('agent_conversations')
@@ -37,9 +43,14 @@ export async function GET(request: Request) {
'id, intent_id, context_ref, title, pinned, archived, last_message_at, last_message_preview, created_at',
)
.eq('company_id', companyId)
.eq('archived', archived)
// Conversations are user-scoped within a company — one member must not
// see another's (see [id]/route.ts). The RLS policy is company-scoped,
// so this filter is what actually prevents cross-member leakage of
// titles and last_message_preview snippets.
.eq('user_id', user.id)
.eq('archived', archived === 'true')
if (pinnedOnly) query = query.eq('pinned', true)
if (pinned === 'true') query = query.eq('pinned', true)
if (intent) query = query.eq('intent_id', intent)
if (q.length > 0) {
// Pattern is sanitized via Postgres' percent-handling; ilike accepts the
@@ -55,6 +66,6 @@ export async function GET(request: Request) {
.limit(limit)
const { data, error } = await query
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) throw error
return NextResponse.json({ data: data ?? [] })
}
})
+57 -53
View File
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
// PATCH /api/agent/memory/[id]
//
@@ -30,61 +30,65 @@ const PatchSchema = z
{ message: 'Nothing to update' },
)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const notFound = () =>
NextResponse.json(
{
error: {
code: 'MEMORY_NOT_FOUND',
message: 'Minnet hittades inte.',
message_en: 'Memory not found.',
},
},
{ status: 404 },
)
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
export const PATCH = withRouteContext(
'agent.memory.update',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, user, log } = ctx
const { id } = await params
const validation = await validateBody(request, PatchSchema, {
log,
operation: 'agent.memory.update',
})
if (!validation.success) return validation.response
const body = validation.data
let body: z.infer<typeof PatchSchema>
try {
body = PatchSchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const update: Record<string, unknown> = {}
if (body.content !== undefined) update.content = body.content
if (body.is_pinned !== undefined) update.is_pinned = body.is_pinned
if (body.is_active !== undefined) update.is_active = body.is_active
const update: Record<string, unknown> = {}
if (body.content !== undefined) update.content = body.content
if (body.is_pinned !== undefined) update.is_pinned = body.is_pinned
if (body.is_active !== undefined) update.is_active = body.is_active
// Look up the row's company_id and re-check membership before mutating.
const { data: existing } = await supabase
.from('agent_memory')
.select('company_id')
.eq('id', id)
.maybeSingle()
if (!existing) return notFound()
// Look up the row's company_id and re-check membership before mutating.
const { data: existing } = await supabase
.from('agent_memory')
.select('company_id')
.eq('id', id)
.maybeSingle()
if (!existing) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', existing.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return notFound()
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', existing.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
const { data, error } = await supabase
.from('agent_memory')
.update(update)
.eq('id', id)
.eq('company_id', existing.company_id)
.select(
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
)
.maybeSingle()
if (error) throw error
if (!data) return notFound()
const { data, error } = await supabase
.from('agent_memory')
.update(update)
.eq('id', id)
.eq('company_id', existing.company_id)
.select(
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
return NextResponse.json({ data })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
+9 -4
View File
@@ -37,7 +37,7 @@ beforeEach(() => {
describe('GET /api/agent/memory', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await GET(createMockRequest('/api/agent/memory'))
const response = await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(401)
expect(body.error).toBe('Unauthorized')
@@ -45,7 +45,7 @@ describe('GET /api/agent/memory', () => {
it('returns 400 when no active company', async () => {
getActiveCompanyIdMock.mockResolvedValue(null)
const response = await GET(createMockRequest('/api/agent/memory'))
const response = await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({}))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
@@ -67,7 +67,7 @@ describe('GET /api/agent/memory', () => {
},
]
enqueue({ data: rows })
const response = await GET(createMockRequest('/api/agent/memory'))
const response = await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{ data: typeof rows }>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
@@ -76,7 +76,7 @@ describe('GET /api/agent/memory', () => {
it('does not require write permission for read', async () => {
enqueue({ data: [] })
await GET(createMockRequest('/api/agent/memory'))
await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({}))
expect(requireWritePermissionMock).not.toHaveBeenCalled()
})
})
@@ -89,6 +89,7 @@ describe('POST /api/agent/memory', () => {
method: 'POST',
body: { content: 'hello world' },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(401)
})
@@ -104,6 +105,7 @@ describe('POST /api/agent/memory', () => {
method: 'POST',
body: { content: 'hello world' },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(403)
})
@@ -114,6 +116,7 @@ describe('POST /api/agent/memory', () => {
method: 'POST',
body: { content: 'x' },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(400)
})
@@ -142,6 +145,7 @@ describe('POST /api/agent/memory', () => {
method: 'POST',
body: { content: 'En sak att komma ihåg' },
}),
createMockRouteParams({}),
)
const { status, body } = await parseJsonResponse<{ data: typeof inserted }>(response)
expect(status).toBe(200)
@@ -155,6 +159,7 @@ describe('POST /api/agent/memory', () => {
method: 'POST',
body: { content: 'En sak att komma ihåg' },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(403)
})
+76 -77
View File
@@ -1,8 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
// GET /api/agent/memory
//
@@ -27,6 +26,15 @@ import { requireWritePermission } from '@/lib/auth/require-write'
const KIND = ['fact', 'preference', 'pattern', 'correction'] as const
const SOURCE = ['composer', 'user_taught', 'agent_learned', 'derived'] as const
const MEMORY_COLUMNS =
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at'
const ListQuerySchema = z.object({
include_dismissed: z.enum(['true', 'false']).optional(),
kind: z.enum(KIND).optional(),
limit: z.coerce.number().int().min(1).max(200).default(200),
})
const BodySchema = z.object({
company_id: z.string().uuid().optional(),
content: z.string().min(2).max(2000),
@@ -38,30 +46,22 @@ const BodySchema = z.object({
relevance_score: z.number().min(0).max(1).default(1.0),
})
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const GET = withRouteContext('agent.memory.list', async (request, ctx) => {
const { supabase, companyId, log } = ctx
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const url = new URL(request.url)
const includeDismissed = url.searchParams.get('include_dismissed') === 'true'
const kindParam = url.searchParams.get('kind')
const kind = KIND.includes(kindParam as (typeof KIND)[number])
? (kindParam as (typeof KIND)[number])
: null
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 200, 1), 200)
const validated = validateQuery(request, ListQuerySchema, {
log,
operation: 'agent.memory.list',
})
if (!validated.success) return validated.response
const { include_dismissed, kind, limit } = validated.data
let query = supabase
.from('agent_memory')
.select(
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
)
.select(MEMORY_COLUMNS)
.eq('company_id', companyId)
if (!includeDismissed) query = query.eq('is_active', true)
if (include_dismissed !== 'true') query = query.eq('is_active', true)
if (kind) query = query.eq('kind', kind)
query = query
@@ -73,64 +73,63 @@ export async function GET(request: Request) {
.limit(limit)
const { data, error } = await query
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) throw error
return NextResponse.json({ data: data ?? [] })
}
})
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const POST = withRouteContext(
'agent.memory.create',
async (request, ctx) => {
const { supabase, companyId: activeCompanyId, user, log } = ctx
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
let body: z.infer<typeof BodySchema>
try {
body = BodySchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// requireWritePermission above checks the *active* company's role; if the
// caller passes a different company_id in the body, re-check membership +
// non-viewer role for THAT company specifically.
const { data: bodyMembership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!bodyMembership || bodyMembership.role === 'viewer') {
return NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
)
}
const { data, error } = await supabase
.from('agent_memory')
.insert({
company_id: companyId,
kind: body.kind,
content: body.content,
source: body.source,
source_ref: body.source_ref ?? null,
relevance_score: body.relevance_score,
is_active: true,
created_by_user_id: user.id,
const validation = await validateBody(request, BodySchema, {
log,
operation: 'agent.memory.create',
})
.select(
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
)
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!validation.success) return validation.response
const body = validation.data
return NextResponse.json({ data })
}
const companyId = body.company_id ?? activeCompanyId
// requireWrite (wrapper option) checks the *active* company's role; if
// the caller passes a different company_id in the body, re-check
// membership + non-viewer role for THAT company specifically.
const { data: bodyMembership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!bodyMembership || bodyMembership.role === 'viewer') {
return NextResponse.json(
{
error: {
code: 'WRITE_PERMISSION_REQUIRED',
message: 'Du har endast läsbehörighet i detta företag.',
message_en: 'You only have read access in this company.',
},
},
{ status: 403 },
)
}
const { data, error } = await supabase
.from('agent_memory')
.insert({
company_id: companyId,
kind: body.kind,
content: body.content,
source: body.source,
source_ref: body.source_ref ?? null,
relevance_score: body.relevance_score,
is_active: true,
created_by_user_id: user.id,
})
.select(MEMORY_COLUMNS)
.single()
if (error) throw error
return NextResponse.json({ data })
},
{ requireWrite: true },
)
+8
View File
@@ -102,6 +102,14 @@ export async function POST(request: Request) {
if (!membership) {
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
}
// The pipeline upserts agent_profiles — a mutation, so viewers are refused
// (same rule as /api/agent/profile and /verify).
if (membership.role === 'viewer') {
return NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
)
}
// No live composer run for sandbox companies: they ship with a pre-built
// verified agent_profile so the chrome is visible without burning Bedrock.
+74 -35
View File
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
// GET /api/agent/profile?company_id=...
// PATCH same path
@@ -11,7 +11,8 @@ import { getActiveCompanyId } from '@/lib/company/context'
//
// PATCH updates field_overrides (timestamped, merged with existing) and
// optionally rewrites the atom arrays from the review UI. Does not touch
// verified_at: that flows through /verify.
// verified_at: that flows through /verify. Requires a non-viewer role in
// the target company (same rule as /verify: it mutates the profile).
const AtomArrays = z.object({
horizontal_atoms: z.array(z.string()).optional(),
@@ -31,15 +32,38 @@ const PatchBody = z.object({
avatar_id: z.string().max(60).nullable().optional(),
})
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const GetQuerySchema = z.object({
company_id: z.string().uuid().optional(),
})
const url = new URL(request.url)
const companyId =
url.searchParams.get('company_id') ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const forbidden = (code: 'NOT_COMPANY_MEMBER' | 'WRITE_PERMISSION_REQUIRED') =>
NextResponse.json(
{
error:
code === 'NOT_COMPANY_MEMBER'
? {
code,
message: 'Du är inte medlem i detta företag.',
message_en: 'Not a member of this company.',
}
: {
code,
message: 'Du har endast läsbehörighet i detta företag.',
message_en: 'You only have read access in this company.',
},
},
{ status: 403 },
)
export const GET = withRouteContext('agent.profile.get', async (request, ctx) => {
const { supabase, companyId: activeCompanyId, user, log } = ctx
const validated = validateQuery(request, GetQuerySchema, {
log,
operation: 'agent.profile.get',
})
if (!validated.success) return validated.response
const companyId = validated.data.company_id ?? activeCompanyId
// Defense in depth alongside RLS: confirm membership before reading.
const { data: membership } = await supabase
@@ -48,7 +72,7 @@ export async function GET(request: Request) {
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
if (!membership) return forbidden('NOT_COMPANY_MEMBER')
const { data, error } = await supabase
.from('agent_profiles')
@@ -57,38 +81,35 @@ export async function GET(request: Request) {
)
.eq('company_id', companyId)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) throw error
if (!data) return NextResponse.json({ data: null })
return NextResponse.json({ data })
}
})
export async function PATCH(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const PATCH = withRouteContext('agent.profile.update', async (request, ctx) => {
const { supabase, companyId: activeCompanyId, user, log } = ctx
let body: z.infer<typeof PatchBody>
try {
body = PatchBody.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const validation = await validateBody(request, PatchBody, {
log,
operation: 'agent.profile.update',
})
if (!validation.success) return validation.response
const body = validation.data
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const companyId = body.company_id ?? activeCompanyId
// RLS guards reads/updates by company_id; defense in depth: confirm membership.
// RLS guards reads/updates by company_id; defense in depth: confirm
// membership AND a non-viewer role (this mutates the company's profile;
// same rule /verify already enforces).
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
if (!membership) return forbidden('NOT_COMPANY_MEMBER')
if (membership.role === 'viewer') return forbidden('WRITE_PERMISSION_REQUIRED')
// Load current overrides to merge timestamp-stamped entries. Avoids round-trip
// when caller sends only an atom-array change.
@@ -98,7 +119,16 @@ export async function PATCH(request: Request) {
.eq('company_id', companyId)
.single()
if (!current) {
return NextResponse.json({ error: 'agent_profile not found for this company' }, { status: 404 })
return NextResponse.json(
{
error: {
code: 'AGENT_PROFILE_NOT_FOUND',
message: 'Det finns ingen agentprofil för detta företag.',
message_en: 'agent_profile not found for this company.',
},
},
{ status: 404 },
)
}
const update: Record<string, unknown> = {}
@@ -120,7 +150,16 @@ export async function PATCH(request: Request) {
if (body.avatar_id !== undefined) update.avatar_id = body.avatar_id
if (Object.keys(update).length === 0) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
return NextResponse.json(
{
error: {
code: 'NOTHING_TO_UPDATE',
message: 'Inget att uppdatera.',
message_en: 'Nothing to update.',
},
},
{ status: 400 },
)
}
const { data, error } = await supabase
@@ -131,7 +170,7 @@ export async function PATCH(request: Request) {
'company_id, horizontal_atoms, vertical_atoms, modifier_atoms, profile_summary, field_overrides',
)
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) throw error
return NextResponse.json({ data })
}
})
+27 -16
View File
@@ -1,7 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
// POST /api/agent/profile/verify
//
@@ -13,23 +12,29 @@ const BodySchema = z.object({
company_id: z.string().uuid().optional(),
})
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const POST = withRouteContext('agent.profile.verify', async (request, ctx) => {
const { supabase, companyId: activeCompanyId, user } = ctx
let body: z.infer<typeof BodySchema>
try {
body = BodySchema.parse(await request.json().catch(() => ({})))
} catch (err) {
// Tolerant parse: the review card POSTs with an empty body when verifying
// the active company.
const raw = await request.json().catch(() => ({}))
const parsed = BodySchema.safeParse(raw)
if (!parsed.success) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{
error: 'Validation failed',
type: 'validation_error',
errors: parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
},
{ status: 400 },
)
}
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const companyId = parsed.data.company_id ?? activeCompanyId
// Defense in depth alongside RLS: confirm membership for the target
// company; a non-viewer role is required to stamp verified_at.
@@ -41,7 +46,13 @@ export async function POST(request: Request) {
.maybeSingle()
if (!membership || membership.role === 'viewer') {
return NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{
error: {
code: 'WRITE_PERMISSION_REQUIRED',
message: 'Du har endast läsbehörighet i detta företag.',
message_en: 'You only have read access in this company.',
},
},
{ status: 403 },
)
}
@@ -55,7 +66,7 @@ export async function POST(request: Request) {
.eq('company_id', companyId)
.select('company_id, verified_at, verified_by_user_id')
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) throw error
return NextResponse.json({ data })
}
})
+29 -14
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
// GET /api/agent/skills
//
@@ -32,27 +33,41 @@ interface AtomMeta {
active: boolean
}
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const QuerySchema = z.object({
slug: z.string().min(1).optional(),
})
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
export const GET = withRouteContext('agent.skills.list', async (request, ctx) => {
const { supabase, companyId, log } = ctx
const url = new URL(request.url)
const slug = url.searchParams.get('slug')
const validated = validateQuery(request, QuerySchema, {
log,
operation: 'agent.skills.list',
})
if (!validated.success) return validated.response
const { slug } = validated.data
// Detail: one atom's body, fetched lazily when the user expands a card.
// The atom registry is global product content (not tenant data), so no
// company filter applies here.
if (slug) {
const { data, error } = await supabase
.from('agent_atom_registry')
.select('id, title, body, is_active, mcp_exposed')
.eq('id', slug)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) throw error
if (!data || !data.is_active || !data.mcp_exposed) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(
{
error: {
code: 'SKILL_NOT_FOUND',
message: 'Kunskapen hittades inte.',
message_en: 'Skill not found.',
},
},
{ status: 404 },
)
}
return NextResponse.json({ data: { id: data.id, title: data.title, body: data.body ?? '' } })
}
@@ -66,7 +81,7 @@ export async function GET(request: Request) {
.is('parent_atom_id', null) // show top-level skills only; reference children are internal
.order('tier', { ascending: true })
.order('title', { ascending: true })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) throw error
const { data: profile } = await supabase
.from('agent_profiles')
@@ -89,4 +104,4 @@ export async function GET(request: Request) {
})
return NextResponse.json({ data: result })
}
})
+18 -14
View File
@@ -7,7 +7,8 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { Article } from '@/types'
ensureInitialized()
@@ -17,22 +18,25 @@ ensureInitialized()
export const GET = withRouteContext(
'article.list',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { supabase, companyId } = ctx
const includeInactive = new URL(request.url).searchParams.get('include_inactive') === '1'
let query = supabase
.from('articles')
.select('*')
.eq('company_id', companyId)
if (!includeInactive) query = query.eq('active', true)
const { data, error } = await query.order('name', { ascending: true })
if (error) {
log.error('article list failed', error)
return errorResponse(error, log, { requestId })
}
// Article registers can exceed PostgREST's silent 1000-row cap (imported
// product catalogs), so paginate. The secondary order on id gives the
// stable total order .range() paging requires — name alone is not unique.
// Errors thrown here surface via the wrapper's canonical envelope.
const data = await fetchAllRows(({ from, to }) => {
let query = supabase
.from('articles')
.select('*')
.eq('company_id', companyId)
if (!includeInactive) query = query.eq('active', true)
return query
.order('name', { ascending: true })
.order('id', { ascending: true })
.range(from, to)
})
return NextResponse.json({ data })
},
+6 -3
View File
@@ -51,8 +51,8 @@ const UpdateAssetSchema = z
// K3 component depreciation. Accepting `null` lets the caller clear an
// existing breakdown (the engine then falls back to depreciation_method).
// Per-component validation runs whenever the field is set to a non-null
// value; the cross-sum check needs acquisition_cost so it's deferred to
// updateAsset() which can read the existing row.
// value; the cross-sum check needs the asset's acquisition_cost so it runs
// in the PATCH handler below, which can read the existing row.
k3_components: z.array(K3ComponentSchema).nullable().optional(),
})
.superRefine((value, ctx) => {
@@ -136,8 +136,11 @@ export const PATCH = withRouteContext(
if (!existing) {
return NextResponse.json({ error: { code: 'ASSET_NOT_FOUND' } }, { status: 404 })
}
// Validate against the cost that will be in effect after this PATCH —
// a body that changes acquisition_cost and k3_components together must
// sum to the NEW cost, not the stored one.
const { errors } = validateComponents({
acquisition_cost: Number(existing.acquisition_cost),
acquisition_cost: validation.data.acquisition_cost ?? Number(existing.acquisition_cost),
k3_components: validation.data.k3_components,
})
if (errors.length > 0) {
+156
View File
@@ -0,0 +1,156 @@
/**
* Tests for GET/PATCH /api/assets/[id].
*
* Exercises the routes through the real withRouteContext wrapper, mocking the
* asset service and auth/company dependencies. The K3 component cross-sum
* validation runs the REAL validateComponents so the regression case (body
* changes acquisition_cost and k3_components together — sum must match the
* NEW cost) is covered end to end.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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/bokslut/assets/asset-service', () => ({
getAsset: vi.fn(),
updateAsset: vi.fn(),
}))
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import { GET, PATCH } from '../[id]/route'
const mockGetAsset = vi.mocked(getAsset)
const mockUpdateAsset = vi.mocked(updateAsset)
const routeParams = { params: Promise.resolve({ id: 'asset-1' }) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('GET /api/assets/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(createMockRequest('/api/assets/asset-1'), routeParams)
expect(res.status).toBe(401)
})
it('returns 404 when the asset does not exist', async () => {
mockGetAsset.mockResolvedValue(null)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(createMockRequest('/api/assets/asset-1'), routeParams)
)
expect(status).toBe(404)
expect(body.error.code).toBe('ASSET_NOT_FOUND')
})
})
describe('PATCH /api/assets/[id]', () => {
it('rejects an invalid body (non-positive acquisition_cost) with 400', async () => {
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
body: { acquisition_cost: -5 },
})
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
expect(status).toBe(400)
expect(mockUpdateAsset).not.toHaveBeenCalled()
})
it('rejects k3_components for a K2 company with 422', async () => {
enqueue({ data: { accounting_framework: 'k2' } })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
body: {
k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }],
},
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await PATCH(req, routeParams)
)
expect(status).toBe(422)
expect(body.error.code).toBe('K3_REQUIRED_FOR_COMPONENTS')
expect(mockUpdateAsset).not.toHaveBeenCalled()
})
it('validates the component sum against the NEW acquisition_cost when both change', async () => {
// Regression: stored cost is 100 000 but the PATCH raises it to 120 000.
// Components summing to 120 000 must pass — previously they were checked
// against the stale stored cost and wrongly rejected.
enqueue({ data: { accounting_framework: 'k3' } })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockUpdateAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 120000 } as any)
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
body: {
acquisition_cost: 120000,
k3_components: [
{ name: 'Stomme', cost: 90000, useful_life_months: 600 },
{ name: 'Tak', cost: 30000, useful_life_months: 240 },
],
},
})
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
expect(status).toBe(200)
expect(mockUpdateAsset).toHaveBeenCalled()
})
it('rejects components that sum to the OLD cost when the PATCH changes the cost', async () => {
enqueue({ data: { accounting_framework: 'k3' } })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
body: {
acquisition_cost: 120000,
k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }],
},
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await PATCH(req, routeParams)
)
expect(status).toBe(400)
expect(body.error.code).toBe('INVALID_K3_COMPONENTS')
expect(mockUpdateAsset).not.toHaveBeenCalled()
})
})
+57 -32
View File
@@ -1,52 +1,75 @@
/**
* Tests for GET /api/audit-trail.
*
* Exercises the route through the real withRouteContext wrapper, mocking its
* auth/company dependencies and the audit service. Covers: auth 401, query
* validation 400, filter passthrough, and the canonical 500 envelope.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
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'),
}))
vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockGetAuditLog = vi.mocked(getAuditLog)
function mockAuth(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
})
describe('GET /api/audit-trail', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = createMockRequest('/api/audit-trail')
const { status, body } = await parseJsonResponse(await GET(req))
const { status, body } = await parseJsonResponse(await GET(req, routeParams))
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns audit log with data and count', async () => {
mockAuth('user-1')
it('returns 400 for an unknown action filter', async () => {
const req = createMockRequest('/api/audit-trail', {
searchParams: { action: 'NOT_AN_ACTION' },
})
const { status } = await parseJsonResponse(await GET(req, routeParams))
expect(status).toBe(400)
expect(mockGetAuditLog).not.toHaveBeenCalled()
})
it('returns 400 for a non-numeric page', async () => {
const req = createMockRequest('/api/audit-trail', {
searchParams: { page: 'abc' },
})
const { status } = await parseJsonResponse(await GET(req, routeParams))
expect(status).toBe(400)
expect(mockGetAuditLog).not.toHaveBeenCalled()
})
it('returns audit log with data and count, defaulting pagination', async () => {
const entries = [
{ id: '1', action: 'INSERT', table_name: 'journal_entries', created_at: '2024-01-01T00:00:00Z' },
{ id: '2', action: 'COMMIT', table_name: 'journal_entries', created_at: '2024-01-02T00:00:00Z' },
@@ -56,7 +79,9 @@ describe('GET /api/audit-trail', () => {
const req = createMockRequest('/api/audit-trail')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(await GET(req))
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(
await GET(req, routeParams)
)
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
@@ -64,12 +89,11 @@ describe('GET /api/audit-trail', () => {
expect(mockGetAuditLog).toHaveBeenCalledWith(
expect.anything(),
'company-1',
expect.objectContaining({})
expect.objectContaining({ page: 1, pageSize: 50 })
)
})
it('passes query param filters to getAuditLog', async () => {
mockAuth('user-1')
mockGetAuditLog.mockResolvedValue({ data: [], count: 0 })
const req = createMockRequest('/api/audit-trail', {
@@ -84,7 +108,7 @@ describe('GET /api/audit-trail', () => {
},
})
await GET(req)
await GET(req, routeParams)
expect(mockGetAuditLog).toHaveBeenCalledWith(
expect.anything(),
@@ -101,14 +125,15 @@ describe('GET /api/audit-trail', () => {
)
})
it('returns 500 on service error', async () => {
mockAuth('user-1')
it('returns the canonical error envelope on service failure', async () => {
mockGetAuditLog.mockRejectedValue(new Error('DB error'))
const req = createMockRequest('/api/audit-trail')
const { status, body } = await parseJsonResponse(await GET(req))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(req, routeParams)
)
expect(status).toBe(500)
expect(body).toEqual({ error: 'DB error' })
expect(body.error.code).toBe('INTERNAL_ERROR')
})
})
+23 -31
View File
@@ -1,38 +1,30 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { AuditTrailQuerySchema } from '@/lib/api/schemas'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditAction } from '@/types'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// GET /api/audit-trail — paginated audit log for the active company.
// The audit log is written exclusively by SECURITY DEFINER triggers; this
// endpoint is read-only.
export const GET = withRouteContext(
'audit_trail.list',
async (request, ctx) => {
const { supabase, companyId, log } = ctx
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const query = validateQuery(request, AuditTrailQuerySchema, {
log,
operation: 'audit_trail.list',
})
if (!query.success) return query.response
const { page, page_size, ...filters } = query.data
const companyId = await requireCompanyId(supabase, user.id)
const result = await getAuditLog(supabase, companyId, {
...filters,
page,
pageSize: page_size,
})
const { searchParams } = new URL(request.url)
const filters = {
action: (searchParams.get('action') as AuditAction) || undefined,
table_name: searchParams.get('table_name') || undefined,
record_id: searchParams.get('record_id') || undefined,
from_date: searchParams.get('from_date') || undefined,
to_date: searchParams.get('to_date') || undefined,
page: searchParams.has('page') ? Number(searchParams.get('page')) : undefined,
pageSize: searchParams.has('page_size') ? Number(searchParams.get('page_size')) : undefined,
}
try {
const result = await getAuditLog(supabase, companyId, filters)
return NextResponse.json({ data: result.data, count: result.count })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch audit log' },
{ status: 500 }
)
}
}
},
)
+116
View File
@@ -0,0 +1,116 @@
/**
* Tests for POST /api/billing/checkout.
*
* Exercises the route through the real withRouteContext wrapper, mocking
* auth/company, the Stripe client, and the service-role Supabase client.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
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'),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
const customersCreate = vi.fn()
const sessionsCreate = vi.fn()
vi.mock('@/lib/stripe/client', () => ({
getStripe: () => ({
customers: { create: customersCreate },
checkout: { sessions: { create: sessionsCreate } },
}),
priceIdForPlan: vi.fn().mockReturnValue('price_123'),
}))
import { POST } from '../checkout/route'
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1', email: 'u@example.com' },
supabase: {},
error: null,
})
})
describe('POST /api/billing/checkout', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const res = await POST(req, routeParams)
expect(res.status).toBe(401)
})
it('rejects an unknown plan with 400', async () => {
const req = createMockRequest('/api/billing/checkout', {
method: 'POST',
body: { plan: 'weekly' },
})
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(400)
expect(sessionsCreate).not.toHaveBeenCalled()
})
it('reuses an existing Stripe customer and returns the checkout URL', async () => {
enqueue({ data: { stripe_customer_id: 'cus_existing' } })
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', {
method: 'POST',
body: { plan: 'yearly' },
})
const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams))
expect(status).toBe(200)
expect(body.url).toBe('https://stripe.test/session')
expect(customersCreate).not.toHaveBeenCalled()
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
customer: 'cus_existing',
client_reference_id: 'company-1',
})
)
})
it('creates a Stripe customer when none exists yet', async () => {
enqueue({ data: null }) // no existing subscription row
enqueue({ data: null }) // upsert result
customersCreate.mockResolvedValue({ id: 'cus_new' })
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams))
expect(status).toBe(200)
expect(body.url).toBe('https://stripe.test/session')
expect(customersCreate).toHaveBeenCalledWith(
expect.objectContaining({ metadata: { company_id: 'company-1' } })
)
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({ customer: 'cus_new' })
)
})
})
+80
View File
@@ -0,0 +1,80 @@
/**
* Tests for POST /api/billing/portal.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
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'),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
const portalCreate = vi.fn()
vi.mock('@/lib/stripe/client', () => ({
getStripe: () => ({
billingPortal: { sessions: { create: portalCreate } },
}),
}))
import { POST } from '../portal/route'
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
})
describe('POST /api/billing/portal', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
const res = await POST(req, routeParams)
expect(res.status).toBe(401)
})
it('returns 400 with NO_SUBSCRIPTION when the company has no Stripe customer', async () => {
enqueue({ data: null })
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await POST(req, routeParams)
)
expect(status).toBe(400)
expect(body.error.code).toBe('NO_SUBSCRIPTION')
expect(portalCreate).not.toHaveBeenCalled()
})
it('returns the portal URL for a company with a Stripe customer', async () => {
enqueue({ data: { stripe_customer_id: 'cus_1' } })
portalCreate.mockResolvedValue({ url: 'https://stripe.test/portal' })
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams))
expect(status).toBe(200)
expect(body.url).toBe('https://stripe.test/portal')
expect(portalCreate).toHaveBeenCalledWith(
expect.objectContaining({ customer: 'cus_1' })
)
})
})
+31 -16
View File
@@ -1,30 +1,45 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { requireCompanyId } from '@/lib/company/context'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { createServiceClient } from '@/lib/supabase/server'
import { getStripe, priceIdForPlan, type BillingPlan } from '@/lib/stripe/client'
import { getStripe, priceIdForPlan } from '@/lib/stripe/client'
const CheckoutSchema = z.object({
plan: z.enum(['monthly', 'yearly']).default('monthly'),
})
/**
* Create a Stripe subscription Checkout Session and return its hosted URL.
* The client redirects to it; provisioning happens via the webhook on
* checkout.session.completed (never trust the success redirect for fulfilment).
*
* company_subscriptions is read/written via the service client on purpose —
* the row is webhook-owned and not member-readable under RLS; every query
* still filters by the membership-validated companyId.
*/
export async function POST(request: Request) {
const { user, supabase, error } = await requireAuth()
if (error) return error
export const POST = withRouteContext('billing.checkout', async (request, ctx) => {
const { user, companyId, log } = ctx
let companyId: string
try {
companyId = await requireCompanyId(supabase, user.id)
} catch {
return NextResponse.json({ error: 'No company context' }, { status: 400 })
}
const validation = await validateBody(request, CheckoutSchema, {
log,
operation: 'billing.checkout',
})
if (!validation.success) return validation.response
const { plan } = validation.data
const body = (await request.json().catch(() => ({}))) as { plan?: string }
const plan: BillingPlan = body.plan === 'yearly' ? 'yearly' : 'monthly'
const priceId = priceIdForPlan(plan)
if (!priceId) {
return NextResponse.json({ error: 'Stripe price not configured' }, { status: 500 })
return NextResponse.json(
{
error: {
code: 'STRIPE_NOT_CONFIGURED',
message: 'Betalning är inte konfigurerad. Kontakta supporten.',
message_en: 'Stripe price not configured.',
},
},
{ status: 500 },
)
}
const stripe = getStripe()
@@ -63,4 +78,4 @@ export async function POST(request: Request) {
})
return NextResponse.json({ url: session.url })
}
})
+18 -14
View File
@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { requireCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createServiceClient } from '@/lib/supabase/server'
import { getStripe } from '@/lib/stripe/client'
@@ -8,17 +7,13 @@ import { getStripe } from '@/lib/stripe/client'
* Create a Stripe Billing Customer Portal session so the user can manage,
* upgrade/downgrade, or cancel their subscription. Stripe handles all the
* compliance/PCI surface: we never build those flows ourselves.
*
* company_subscriptions is read via the service client on purpose: the row
* is webhook-owned and not member-readable under RLS; the query still filters
* by the membership-validated companyId.
*/
export async function POST() {
const { user, supabase, error } = await requireAuth()
if (error) return error
let companyId: string
try {
companyId = await requireCompanyId(supabase, user.id)
} catch {
return NextResponse.json({ error: 'No company context' }, { status: 400 })
}
export const POST = withRouteContext('billing.portal', async (_request, ctx) => {
const { companyId } = ctx
const service = createServiceClient()
const { data: sub } = await service
@@ -29,7 +24,16 @@ export async function POST() {
const customerId = (sub as { stripe_customer_id: string | null } | null)?.stripe_customer_id
if (!customerId) {
return NextResponse.json({ error: 'No subscription to manage' }, { status: 400 })
return NextResponse.json(
{
error: {
code: 'NO_SUBSCRIPTION',
message: 'Det finns inget abonnemang att hantera.',
message_en: 'No subscription to manage.',
},
},
{ status: 400 },
)
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''
@@ -39,4 +43,4 @@ export async function POST() {
})
return NextResponse.json({ url: portal.url })
}
})
+8 -16
View File
@@ -1,13 +1,9 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { AccountBalancesQuerySchema } from '@/lib/api/schemas'
import { getOpeningBalances } from '@/lib/reports/opening-balances'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { createLogger } from '@/lib/logger'
const log = createLogger('api.bookkeeping.account-balances')
/**
* Per-account saldo as of a date. Used by the journal-entry form to show
@@ -24,20 +20,16 @@ const log = createLogger('api.bookkeeping.account-balances')
* companies behave identically. The opening-balance entry is excluded from
* period activity to avoid double-counting its lines.
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const GET = withRouteContext('bookkeeping.account_balances', async (request, ctx) => {
const { supabase, companyId, log } = ctx
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const params = validateQuery(request, AccountBalancesQuerySchema)
const params = validateQuery(request, AccountBalancesQuerySchema, {
log,
operation: 'bookkeeping.account_balances',
})
if (!params.success) return params.response
const { accounts, as_of } = params.data
const companyId = await requireCompanyId(supabase, user.id)
// Find the fiscal period containing as_of (any state: we want a reference
// saldo even for closed/locked periods).
const { data: period, error: periodError } = await supabase
@@ -159,4 +151,4 @@ export async function GET(request: Request) {
}
}),
})
}
})
+56 -55
View File
@@ -1,59 +1,57 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// GET /api/bookkeeping/account-totals?from=3000&to=3999[&date_from=..&date_to=..&group_by=month]
//
// Sums posted debit/credit per account in an account-number range, optionally
// bucketed by month. Both the entry list and the per-batch line fetches are
// paginated — PostgREST caps unpaginated selects at 1000 rows, which would
// silently under-count totals for companies with large journals.
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const QuerySchema = z.object({
from: z.string().regex(/^\d{4}$/, 'from must be a 4-digit account number'),
to: z.string().regex(/^\d{4}$/, 'to must be a 4-digit account number'),
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
group_by: z.enum(['month']).optional(),
})
const companyId = await requireCompanyId(supabase, user.id)
export const GET = withRouteContext('bookkeeping.account_totals', async (request, ctx) => {
const { supabase, companyId, log } = ctx
const { searchParams } = new URL(request.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
const groupBy = searchParams.get('group_by')
const validated = validateQuery(request, QuerySchema, {
log,
operation: 'bookkeeping.account_totals',
})
if (!validated.success) return validated.response
const { from, to, date_from: dateFrom, date_to: dateTo, group_by: groupBy } = validated.data
if (!from || !to) {
return NextResponse.json(
{ error: 'from and to account numbers are required' },
{ status: 400 }
)
}
// Posted entries in range — paginated (large journals exceed 1000 entries).
const entries = await fetchAllRows<{ id: string; entry_date: string }>(({ from: f, to: t }) => {
let query = supabase
.from('journal_entries')
.select('id, entry_date')
.eq('company_id', companyId)
.eq('status', 'posted')
// Get posted journal entries within date range
let entriesQuery = supabase
.from('journal_entries')
.select('id, entry_date')
.eq('company_id', companyId)
.eq('status', 'posted')
if (dateFrom) query = query.gte('entry_date', dateFrom)
if (dateTo) query = query.lte('entry_date', dateTo)
if (dateFrom) {
entriesQuery = entriesQuery.gte('entry_date', dateFrom)
}
if (dateTo) {
entriesQuery = entriesQuery.lte('entry_date', dateTo)
}
return query.order('id', { ascending: true }).range(f, t)
})
const { data: entries, error: entriesError } = await entriesQuery
if (entriesError) {
return NextResponse.json({ error: entriesError.message }, { status: 500 })
}
if (!entries || entries.length === 0) {
if (entries.length === 0) {
return NextResponse.json({ totals: [], monthly: groupBy === 'month' ? [] : undefined })
}
const entryIds = entries.map((e) => e.id)
const entryDateMap = new Map(entries.map((e) => [e.id, e.entry_date]))
// Fetch lines in batches to avoid URL length limits
// Fetch lines in id-batches to avoid URL length limits; each batch is
// itself paginated (200 entries can easily carry >1000 lines).
const batchSize = 200
const allLines: Array<{
journal_entry_id: string
@@ -64,19 +62,22 @@ export async function GET(request: Request) {
for (let i = 0; i < entryIds.length; i += batchSize) {
const batch = entryIds.slice(i, i + batchSize)
const { data: lines, error: linesError } = await supabase
.from('journal_entry_lines')
.select('journal_entry_id, account_number, debit_amount, credit_amount')
.in('journal_entry_id', batch)
.gte('account_number', from)
.lte('account_number', to)
if (linesError) {
return NextResponse.json({ error: linesError.message }, { status: 500 })
}
if (lines) {
allLines.push(...lines)
}
const lines = await fetchAllRows<{
journal_entry_id: string
account_number: string
debit_amount: number
credit_amount: number
}>(({ from: f, to: t }) =>
supabase
.from('journal_entry_lines')
.select('journal_entry_id, account_number, debit_amount, credit_amount')
.in('journal_entry_id', batch)
.gte('account_number', from)
.lte('account_number', to)
.order('id', { ascending: true })
.range(f, t)
)
allLines.push(...lines)
}
// Aggregate by account
@@ -132,4 +133,4 @@ export async function GET(request: Request) {
}
return NextResponse.json({ totals })
}
})
+85 -85
View File
@@ -1,104 +1,104 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ number: string }> }
) {
const { number } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// DELETE hard-deletes an unused, non-system account; accounts referenced by
// this company's journal entries must be deactivated instead (PUT is_active).
// Response shapes are legacy `{ error: string }` — the kontoplan UI renders
// them directly.
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
export const DELETE = withRouteContext(
'bookkeeping.accounts.delete',
async (_request, ctx, { params }: { params: Promise<{ number: string }> }) => {
const { number } = await params
const { supabase, companyId } = ctx
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
// Fetch the account to check if it's a system account
const { data: account, error: fetchError } = await supabase
.from('chart_of_accounts')
.select('id, is_system_account')
.eq('company_id', companyId)
.eq('account_number', number)
.single()
const companyId = await requireCompanyId(supabase, user.id)
if (fetchError || !account) {
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
}
// Fetch the account to check if it's a system account
const { data: account, error: fetchError } = await supabase
.from('chart_of_accounts')
.select('id, is_system_account')
.eq('company_id', companyId)
.eq('account_number', number)
.single()
if (account.is_system_account) {
return NextResponse.json(
{ error: 'Systemkonton kan inte tas bort' },
{ status: 400 }
)
}
if (fetchError || !account) {
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
}
// Check if the account is referenced in THIS company's journal entries.
// journal_entry_lines has no company_id column, so scope via the parent
// entry — a user can be a member of several companies, and another
// company's usage of the same BAS number must not block deletion here.
const { count } = await supabase
.from('journal_entry_lines')
.select('id, journal_entries!inner(company_id)', { count: 'exact', head: true })
.eq('journal_entries.company_id', companyId)
.eq('account_number', number)
if (account.is_system_account) {
return NextResponse.json(
{ error: 'Systemkonton kan inte tas bort' },
{ status: 400 }
)
}
if (count && count > 0) {
return NextResponse.json(
{ error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' },
{ status: 400 }
)
}
// Check if account is referenced in posted journal entries
const { count } = await supabase
.from('journal_entry_lines')
.select('id', { count: 'exact', head: true })
.eq('account_number', number)
const { error: deleteError } = await supabase
.from('chart_of_accounts')
.delete()
.eq('id', account.id)
.eq('company_id', companyId)
if (count && count > 0) {
return NextResponse.json(
{ error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' },
{ status: 400 }
)
}
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
const { error: deleteError } = await supabase
.from('chart_of_accounts')
.delete()
.eq('id', account.id)
.eq('company_id', companyId)
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
export const PUT = withRouteContext(
'bookkeeping.accounts.update',
async (request, ctx, { params }: { params: Promise<{ number: string }> }) => {
const { number } = await params
const { supabase, companyId, log } = ctx
return NextResponse.json({ success: true })
}
const validation = await validateBody(request, UpdateAccountSchema, {
log,
operation: 'bookkeeping.accounts.update',
})
if (!validation.success) return validation.response
const body = validation.data
export async function PUT(
request: Request,
{ params }: { params: Promise<{ number: string }> }
) {
const { number } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (Object.keys(body).length === 0) {
return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 })
}
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('chart_of_accounts')
.update(body)
.eq('company_id', companyId)
.eq('account_number', number)
.select()
.single()
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (error) {
// PGRST116 = zero rows — the account doesn't exist in this company.
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, UpdateAccountSchema)
if (!validation.success) return validation.response
const body = validation.data
const { data, error } = await supabase
.from('chart_of_accounts')
.update(body)
.eq('company_id', companyId)
.eq('account_number', number)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
@@ -0,0 +1,269 @@
/**
* Tests for /api/bookkeeping/accounts (list/create), /[number] (update/delete)
* and /activate.
*
* The DELETE usage check is asserted with a call-capturing mock: the count
* query must be scoped to the caller's company via the journal_entries join —
* without it, another company's use of the same BAS number (same user,
* multiple memberships under RLS) wrongly blocks deletion.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
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),
}))
import { GET as listGET, POST as createPOST } from '../route'
import { DELETE, PUT } from '../[number]/route'
import { POST as activatePOST } from '../activate/route'
interface CapturedCall {
method: string
args: unknown[]
}
/** Chainable builder recording calls; resolves queued {data,error,count} per from(). */
function createCapturingSupabase(
results: { data?: unknown; error?: unknown; count?: number | null }[]
) {
const calls: CapturedCall[] = []
let idx = 0
const makeBuilder = () => {
const result = results[idx++] ?? { data: null, error: null, count: null }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: any = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'is', 'order', 'limit', 'range', 'insert', 'update', 'delete', 'maybeSingle', 'single']) {
b[m] = (...args: unknown[]) => {
calls.push({ method: m, args })
return b
}
}
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null, count: result.count ?? null })
return b
}
const supabase = {
from: (table: string) => {
calls.push({ method: 'from', args: [table] })
return makeBuilder()
},
}
return { supabase, calls }
}
const routeParams = { params: Promise.resolve({}) }
const numberParams = { params: Promise.resolve({ number: '5010' }) }
beforeEach(() => {
vi.clearAllMocks()
requireWriteMock.mockResolvedValue({ ok: true })
})
function auth(supabase: unknown) {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
describe('GET /api/bookkeeping/accounts', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
expect(res.status).toBe(401)
})
it('returns 400 for a non-numeric class filter', async () => {
const { supabase } = createCapturingSupabase([])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts', { searchParams: { class: 'abc' } })
const { status } = await parseJsonResponse(await listGET(req, routeParams))
expect(status).toBe(400)
})
it('lists accounts for the company', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }] },
])
auth(supabase)
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
expect(calls.filter((c) => c.method === 'eq').map((c) => c.args)).toContainEqual([
'company_id',
'company-1',
])
})
})
describe('POST /api/bookkeeping/accounts', () => {
it('returns 409 with a Swedish message on duplicate account number', async () => {
const { supabase } = createCapturingSupabase([{ error: { code: '23505', message: 'dup' } }])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts', {
method: 'POST',
body: {
account_number: '5010',
account_name: 'Lokalhyra',
account_type: 'expense',
normal_balance: 'debit',
},
})
const { status, body } = await parseJsonResponse<{ error: string }>(
await createPOST(req, routeParams)
)
expect(status).toBe(409)
expect(body.error).toContain('5010')
})
})
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
it('scopes the usage check to the company via the journal_entries join', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: { id: 'acc-1', is_system_account: false } }, // account fetch
{ count: 0 }, // usage count
{ data: null }, // delete
])
auth(supabase)
const { status } = await parseJsonResponse(
await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams)
)
expect(status).toBe(200)
const selectArgs = calls.filter((c) => c.method === 'select').map((c) => c.args[0])
expect(selectArgs).toContain('id, journal_entries!inner(company_id)')
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
expect(eqCalls).toContainEqual(['journal_entries.company_id', 'company-1'])
})
it('refuses deleting an account used in this company with 400', async () => {
const { supabase } = createCapturingSupabase([
{ data: { id: 'acc-1', is_system_account: false } },
{ count: 3 },
])
auth(supabase)
const { status, body } = await parseJsonResponse<{ error: string }>(
await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams)
)
expect(status).toBe(400)
expect(body.error).toContain('Inaktivera')
})
it('refuses deleting a system account', async () => {
const { supabase } = createCapturingSupabase([
{ data: { id: 'acc-1', is_system_account: true } },
])
auth(supabase)
const { status } = await parseJsonResponse(
await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams)
)
expect(status).toBe(400)
})
})
describe('PUT /api/bookkeeping/accounts/[number]', () => {
it('returns 400 when the body has nothing to update', async () => {
const { supabase } = createCapturingSupabase([])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/5010', { method: 'PUT', body: {} })
const { status } = await parseJsonResponse(await PUT(req, numberParams))
expect(status).toBe(400)
})
it('maps zero-rows (PGRST116) to 404', async () => {
const { supabase } = createCapturingSupabase([
{ error: { code: 'PGRST116', message: 'no rows' } },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
method: 'PUT',
body: { account_name: 'Nytt namn' },
})
const { status, body } = await parseJsonResponse<{ error: string }>(await PUT(req, numberParams))
expect(status).toBe(404)
expect(body.error).toBe('Kontot hittades inte')
})
it('updates the account', async () => {
const { supabase } = createCapturingSupabase([
{ data: { account_number: '5010', account_name: 'Nytt namn' } },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
method: 'PUT',
body: { account_name: 'Nytt namn' },
})
const { status, body } = await parseJsonResponse<{ data: { account_name: string } }>(
await PUT(req, numberParams)
)
expect(status).toBe(200)
expect(body.data.account_name).toBe('Nytt namn')
})
})
describe('POST /api/bookkeeping/accounts/activate', () => {
it('returns 400 (not a crash) on invalid JSON', async () => {
const { supabase } = createCapturingSupabase([])
auth(supabase)
const req = new Request('http://localhost/api/bookkeeping/accounts/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{not json',
})
const { status, body } = await parseJsonResponse<{ error: string }>(
await activatePOST(req, routeParams)
)
expect(status).toBe(400)
expect(body.error).toBe('account_numbers array required')
})
it('returns 400 when account_numbers is missing or empty', async () => {
const { supabase } = createCapturingSupabase([])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
method: 'POST',
body: { account_numbers: [] },
})
const { status } = await parseJsonResponse(await activatePOST(req, routeParams))
expect(status).toBe(400)
})
it('activates a known BAS account and buckets unknown numbers', async () => {
const { supabase } = createCapturingSupabase([
{ data: [] }, // existing lookup — none in chart
{ data: [{ account_number: '1930' }] }, // insert result
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
method: 'POST',
body: { account_numbers: ['1930', '0000'] },
})
const { status, body } = await parseJsonResponse<{
activated: number
unknown: string[]
}>(await activatePOST(req, routeParams))
expect(status).toBe(200)
expect(body.activated).toBe(1)
expect(body.unknown).toEqual(['0000'])
})
})
@@ -0,0 +1,257 @@
/**
* Tests for /api/bookkeeping/accounts/usage and /prune.
*
* The prune execute phase is the safety-critical part: the client's
* account_numbers list is a selection, not an authority. The tests assert
* that used accounts, system accounts, and unknown numbers sent by the
* client are skipped/reported — only freshly re-verified unused accounts
* reach the DELETE.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
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),
}))
import { GET as usageGET } from '../usage/route'
import { POST as prunePOST } from '../prune/route'
interface CapturedCall {
method: string
args: unknown[]
}
/**
* Chainable builder recording calls; resolves queued {data,error} per
* from()/rpc() invocation, in call order.
*/
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
const calls: CapturedCall[] = []
let idx = 0
const nextResult = () => results[idx++] ?? { data: null, error: null }
const makeBuilder = () => {
const result = nextResult()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: any = {}
for (const m of ['select', 'eq', 'in', 'order', 'range', 'insert', 'update', 'delete']) {
b[m] = (...args: unknown[]) => {
calls.push({ method: m, args })
return b
}
}
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
return b
}
const supabase = {
from: (table: string) => {
calls.push({ method: 'from', args: [table] })
return makeBuilder()
},
rpc: (fn: string, params: unknown) => {
calls.push({ method: 'rpc', args: [fn, params] })
const result = nextResult()
return Promise.resolve({ data: result.data ?? null, error: result.error ?? null })
},
}
return { supabase, calls }
}
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
requireWriteMock.mockResolvedValue({ ok: true })
})
function auth(supabase: unknown) {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
function unauthenticated() {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
// Chart fixture: one system account, one used BAS account, one unused BAS
// account from the seed, one unused imported custom account.
const chartAccounts = [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1, plan_type: 'k1', is_active: true, is_system_account: true },
{ account_number: '3001', account_name: 'Försäljning', account_class: 3, plan_type: 'k1', is_active: true, is_system_account: false },
{ account_number: '5410', account_name: 'Förbrukningsinventarier', account_class: 5, plan_type: 'k1', is_active: true, is_system_account: false },
{ account_number: '19301', account_name: 'Sparkonto (import)', account_class: 1, plan_type: 'full_bas', is_active: true, is_system_account: false },
]
const usageRows = [
{ account_number: '1930', usage_count: 12 },
{ account_number: '3001', usage_count: 4 },
]
describe('GET /api/bookkeeping/accounts/usage', () => {
it('returns 401 when not authenticated', async () => {
unauthenticated()
const res = await usageGET(createMockRequest('/api/bookkeeping/accounts/usage'), routeParams)
expect(res.status).toBe(401)
})
it('returns per-account usage counts from the RPC, company-scoped', async () => {
const { supabase, calls } = createCapturingSupabase([{ data: usageRows }])
auth(supabase)
const { status, body } = await parseJsonResponse<{ data: typeof usageRows }>(
await usageGET(createMockRequest('/api/bookkeeping/accounts/usage'), routeParams),
)
expect(status).toBe(200)
expect(body.data).toEqual(usageRows)
expect(calls.filter((c) => c.method === 'rpc').map((c) => c.args)).toContainEqual([
'get_account_usage_counts',
{ p_company_id: 'company-1' },
])
})
it('returns 500 when the RPC fails', async () => {
const { supabase } = createCapturingSupabase([{ error: { message: 'boom' } }])
auth(supabase)
const res = await usageGET(createMockRequest('/api/bookkeeping/accounts/usage'), routeParams)
expect(res.status).toBe(500)
})
})
describe('POST /api/bookkeeping/accounts/prune', () => {
it('returns 401 when not authenticated', async () => {
unauthenticated()
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
method: 'POST',
body: { dry_run: true },
})
const res = await prunePOST(req, routeParams)
expect(res.status).toBe(401)
})
it('returns 400 when dry_run is false and account_numbers is missing', async () => {
const { supabase } = createCapturingSupabase([])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
method: 'POST',
body: { dry_run: false },
})
const { status } = await parseJsonResponse(await prunePOST(req, routeParams))
expect(status).toBe(400)
})
it('dry_run returns unused non-system accounts as deletable, the rest as used', async () => {
const { supabase } = createCapturingSupabase([
{ data: chartAccounts }, // chart_of_accounts page
{ data: usageRows }, // usage RPC
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
method: 'POST',
body: { dry_run: true },
})
const { status, body } = await parseJsonResponse<{
data: {
deletable: Array<{ account_number: string; in_bas_reference: boolean }>
used: Array<{ account_number: string; usage_count: number }>
}
}>(await prunePOST(req, routeParams))
expect(status).toBe(200)
const deletableNumbers = body.data.deletable.map((a) => a.account_number).sort()
// Unused + non-system: the seeded 5410 and the imported 19301.
expect(deletableNumbers).toEqual(['19301', '5410'])
// BAS-vs-custom marker drives the dialog's default selection.
expect(body.data.deletable.find((a) => a.account_number === '5410')?.in_bas_reference).toBe(true)
expect(body.data.deletable.find((a) => a.account_number === '19301')?.in_bas_reference).toBe(false)
// Used + system accounts land in the informational remainder.
const usedNumbers = body.data.used.map((a) => a.account_number)
expect(usedNumbers).toContain('1930')
expect(usedNumbers).toContain('3001')
expect(body.data.used.find((a) => a.account_number === '3001')?.usage_count).toBe(4)
})
it('execute deletes only re-verified unused accounts and skips the rest', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: chartAccounts },
{ data: usageRows },
{ data: null }, // delete chunk
])
auth(supabase)
// Client asks for a used account (3001), a system account (1930), an
// unknown number (9999) and two legitimately deletable ones.
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
method: 'POST',
body: { dry_run: false, account_numbers: ['3001', '1930', '9999', '5410', '19301'] },
})
const { status, body } = await parseJsonResponse<{
data: { deleted: string[]; skipped: string[]; not_found: string[] }
}>(await prunePOST(req, routeParams))
expect(status).toBe(200)
expect(body.data.deleted.sort()).toEqual(['19301', '5410'])
expect(body.data.skipped.sort()).toEqual(['1930', '3001'])
expect(body.data.not_found).toEqual(['9999'])
// The DELETE is company-scoped, guards system accounts, and only carries
// the re-verified numbers.
const inCalls = calls.filter((c) => c.method === 'in').map((c) => c.args)
expect(inCalls).toContainEqual(['account_number', ['5410', '19301']])
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
expect(eqCalls).toContainEqual(['is_system_account', false])
})
it('execute with nothing deletable deletes nothing and reports the skips', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: chartAccounts },
{ data: usageRows },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
method: 'POST',
body: { dry_run: false, account_numbers: ['3001'] },
})
const { status, body } = await parseJsonResponse<{
data: { deleted: string[]; skipped: string[] }
}>(await prunePOST(req, routeParams))
expect(status).toBe(200)
expect(body.data.deleted).toEqual([])
expect(body.data.skipped).toEqual(['3001'])
expect(calls.filter((c) => c.method === 'delete')).toHaveLength(0)
})
it('returns 403 when the member lacks write permission', async () => {
const { supabase } = createCapturingSupabase([])
auth(supabase)
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
method: 'POST',
body: { dry_run: true },
})
const res = await prunePOST(req, routeParams)
expect(res.status).toBe(403)
})
})
@@ -0,0 +1,121 @@
/**
* Tests for GET /api/bookkeeping/accounts/reference and /bas-lookup.
*
* reference: the chart query must carry a stable unique order — a full-BAS
* chart exceeds fetchAllRows' 1000-row page size and unordered .range()
* paging can duplicate/skip rows on page boundaries.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
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'),
}))
import { GET as referenceGET } from '../reference/route'
import { GET as basLookupGET } from '../bas-lookup/route'
const routeParams = { params: Promise.resolve({}) }
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
const calls: { method: string; args: unknown[] }[] = []
let idx = 0
const makeBuilder = () => {
const result = results[idx++] ?? { data: null, error: null }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: any = {}
for (const m of ['select', 'eq', 'order', 'range', 'maybeSingle', 'single']) {
b[m] = (...args: unknown[]) => {
calls.push({ method: m, args })
return b
}
}
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null, count: null })
return b
}
return {
supabase: { from: () => makeBuilder() },
calls,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/bookkeeping/accounts/reference', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await referenceGET(createMockRequest('/api/bookkeeping/accounts/reference'), routeParams)
expect(res.status).toBe(401)
})
it('pages the chart with a stable account_number order and merges activation status', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: [{ account_number: '1930', is_active: true, is_system_account: false }] },
])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const { status, body } = await parseJsonResponse<{
data: Array<{ account_number: string; is_activated: boolean }>
}>(await referenceGET(createMockRequest('/api/bookkeeping/accounts/reference'), routeParams))
expect(status).toBe(200)
const activated = body.data.find((a) => a.account_number === '1930')
expect(activated?.is_activated).toBe(true)
// Paging-stability regression guard.
expect(calls.filter((c) => c.method === 'order').map((c) => c.args[0])).toContain(
'account_number'
)
})
})
describe('GET /api/bookkeeping/accounts/bas-lookup', () => {
beforeEach(() => {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await basLookupGET(createMockRequest('/api/bookkeeping/accounts/bas-lookup'))
expect(res.status).toBe(401)
})
it('resolves known BAS numbers and flags unknown ones', async () => {
const req = createMockRequest('/api/bookkeeping/accounts/bas-lookup', {
searchParams: { numbers: '1930,0000' },
})
const { status, body } = await parseJsonResponse<{
data: Array<{ account_number: string; known: boolean }>
}>(await basLookupGET(req))
expect(status).toBe(200)
expect(body.data.find((a) => a.account_number === '1930')?.known).toBe(true)
expect(body.data.find((a) => a.account_number === '0000')?.known).toBe(false)
})
it('rejects an oversized numbers list with 400', async () => {
const many = Array.from({ length: 2001 }, (_, i) => String(10000 + i)).join(',')
const req = createMockRequest('/api/bookkeeping/accounts/bas-lookup', {
searchParams: { numbers: many },
})
const { status } = await parseJsonResponse(await basLookupGET(req))
expect(status).toBe(400)
})
})
+87 -87
View File
@@ -1,8 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* POST /api/bookkeeping/accounts/activate
@@ -12,100 +11,101 @@ import { requireWritePermission } from '@/lib/auth/require-write'
* - Reactivates (is_active=true) accounts that already exist but are inactive.
* - Skips anything already active.
* - Returns { activated, reactivated, skipped, unknown } so callers can react.
*
* Strings that aren't known BAS numbers are reported in `unknown` (not
* rejected) so activate-and-retry flows can surface them; the schema only
* bounds type and size.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const ActivateSchema = z.object({
account_numbers: z.array(z.string().min(1).max(10)).min(1).max(2000),
})
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
export const POST = withRouteContext(
'bookkeeping.accounts.activate',
async (request, ctx) => {
const { supabase, companyId, user } = ctx
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const accountNumbers: string[] = body.account_numbers
if (!Array.isArray(accountNumbers) || accountNumbers.length === 0) {
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
}
const uniqueNumbers = [...new Set(accountNumbers)]
// Fetch existing rows with current is_active state
const { data: existing, error: fetchError } = await supabase
.from('chart_of_accounts')
.select('account_number, is_active')
.eq('company_id', companyId)
.in('account_number', uniqueNumbers)
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
const existingByNumber = new Map<string, boolean>(
(existing || []).map((a) => [a.account_number, a.is_active])
)
const toReactivate: string[] = []
const toInsert: Array<ReturnType<typeof buildInsertRow>> = []
const unknown: string[] = []
let skipped = 0
for (const num of uniqueNumbers) {
if (existingByNumber.has(num)) {
if (existingByNumber.get(num) === true) {
skipped += 1
} else {
toReactivate.push(num)
}
continue
const raw = await request.json().catch(() => null)
const parsed = ActivateSchema.safeParse(raw)
if (!parsed.success) {
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
}
const row = buildInsertRow(num, user.id, companyId)
if (row) {
toInsert.push(row)
} else {
unknown.push(num)
}
}
let reactivatedRows: { account_number: string }[] = []
if (toReactivate.length > 0) {
const { data, error } = await supabase
const uniqueNumbers = [...new Set(parsed.data.account_numbers)]
// Fetch existing rows with current is_active state
const { data: existing, error: fetchError } = await supabase
.from('chart_of_accounts')
.update({ is_active: true })
.select('account_number, is_active')
.eq('company_id', companyId)
.in('account_number', toReactivate)
.select('account_number')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
reactivatedRows = data || []
}
.in('account_number', uniqueNumbers)
let insertedRows: { account_number: string }[] = []
if (toInsert.length > 0) {
const { data, error } = await supabase
.from('chart_of_accounts')
.insert(toInsert)
.select('account_number')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
insertedRows = data || []
}
return NextResponse.json({
data: [...insertedRows, ...reactivatedRows],
activated: insertedRows.length,
reactivated: reactivatedRows.length,
skipped,
unknown,
})
}
const existingByNumber = new Map<string, boolean>(
(existing || []).map((a) => [a.account_number, a.is_active])
)
const toReactivate: string[] = []
const toInsert: Array<ReturnType<typeof buildInsertRow>> = []
const unknown: string[] = []
let skipped = 0
for (const num of uniqueNumbers) {
if (existingByNumber.has(num)) {
if (existingByNumber.get(num) === true) {
skipped += 1
} else {
toReactivate.push(num)
}
continue
}
const row = buildInsertRow(num, user.id, companyId)
if (row) {
toInsert.push(row)
} else {
unknown.push(num)
}
}
let reactivatedRows: { account_number: string }[] = []
if (toReactivate.length > 0) {
const { data, error } = await supabase
.from('chart_of_accounts')
.update({ is_active: true })
.eq('company_id', companyId)
.in('account_number', toReactivate)
.select('account_number')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
reactivatedRows = data || []
}
let insertedRows: { account_number: string }[] = []
if (toInsert.length > 0) {
const { data, error } = await supabase
.from('chart_of_accounts')
.insert(toInsert)
.select('account_number')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
insertedRows = data || []
}
return NextResponse.json({
data: [...insertedRows, ...reactivatedRows],
activated: insertedRows.length,
reactivated: reactivatedRows.length,
skipped,
unknown,
})
},
{ requireWrite: true },
)
function buildInsertRow(accountNumber: string, userId: string, companyId: string) {
const ref = getBASReference(accountNumber)
@@ -1,5 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
/**
@@ -9,13 +9,13 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference'
* numbers. Used by ActivateAccountsDialog to render human-readable labels
* before the user confirms activation. Unknown numbers are returned with
* account_name=null so the UI can flag them as non-BAS.
*
* Pure in-memory reference lookup — no tenant data, so no company context is
* resolved; requireAuth() keeps it behind auth (MFA on hosted).
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const auth = await requireAuth()
if (auth.error) return auth.error
const { searchParams } = new URL(request.url)
const raw = searchParams.get('numbers') || ''
@@ -23,6 +23,10 @@ export async function GET(request: Request) {
if (numbers.length === 0) {
return NextResponse.json({ data: [] })
}
// The BAS catalogue is ~1,276 accounts — anything past that is abuse.
if (numbers.length > 2000) {
return NextResponse.json({ error: 'Too many account numbers' }, { status: 400 })
}
const data = numbers.map((num) => {
const ref = getBASReference(num)
+162
View File
@@ -0,0 +1,162 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { PruneAccountsSchema } from '@/lib/api/schemas'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
// POST /api/bookkeeping/accounts/prune — bulk cleanup of unused accounts
// ("Rensa oanvända konton"), for charts bloated by an import from an old
// system.
//
// Two phases:
// { dry_run: true }
// → returns the deletable set (non-system accounts with zero journal
// usage) plus the used remainder, without changing anything.
// { dry_run: false, account_numbers: [...] }
// → deletes the requested accounts, re-verifying every guard server-side.
// The client list is a selection from the preview, not an authority:
// anything failing re-check at execute time is skipped and reported,
// never deleted and never an error.
//
// A used account can never be deleted through this path — its verifikat are
// immutable under BFL and their lines must keep resolving to an account.
// Deactivation (PUT is_active=false on the single-account route) remains the
// only way to hide those. Draft usage also blocks deletion: a draft line
// still references the account. Opening balances need no separate check —
// IB is booked as a verifikat (source_type 'opening_balance'), so the journal
// usage count covers it. (The account_balances cache table was dropped in
// migration 20240101000027.)
//
// Like the sibling single-account DELETE, there is a small window between
// the usage re-check and the delete where a concurrent posting could slip
// in; journal_entry_lines reference accounts by number (account_id is ON
// DELETE SET NULL), so the entry itself is never damaged — the account row
// would just need re-adding from the BAS catalog.
//
// Response shapes are legacy `{ data }` / `{ error: string }` — consumed by
// the kontoplan UI alongside the sibling account routes.
interface ChartAccountRow {
account_number: string
account_name: string
account_class: number
plan_type: string | null
is_active: boolean
is_system_account: boolean
}
const DELETE_CHUNK_SIZE = 200
export const POST = withRouteContext(
'bookkeeping.accounts.prune',
async (request, ctx) => {
const { supabase, companyId, log } = ctx
const validation = await validateBody(request, PruneAccountsSchema, {
log,
operation: 'bookkeeping.accounts.prune',
})
if (!validation.success) return validation.response
const { dry_run, account_numbers } = validation.data
try {
// A full imported chart can exceed PostgREST's 1000-row page — paginate.
const accounts = (await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select(
'account_number, account_name, account_class, plan_type, is_active, is_system_account',
)
.eq('company_id', companyId)
.order('account_number')
.range(from, to),
)) as ChartAccountRow[]
const { data: usage, error: usageError } = await supabase.rpc(
'get_account_usage_counts',
{ p_company_id: companyId },
)
if (usageError) {
return NextResponse.json({ error: usageError.message }, { status: 500 })
}
const usageByAccount = new Map<string, number>(
(usage ?? []).map((u: { account_number: string; usage_count: number }) => [
u.account_number,
Number(u.usage_count),
]),
)
const isDeletable = (a: ChartAccountRow) =>
!a.is_system_account && !usageByAccount.has(a.account_number)
if (dry_run) {
const deletable = accounts.filter(isDeletable).map((a) => ({
account_number: a.account_number,
account_name: a.account_name,
account_class: a.account_class,
plan_type: a.plan_type,
is_active: a.is_active,
in_bas_reference: isStandardBASAccount(a.account_number),
}))
const used = accounts
.filter((a) => !isDeletable(a))
.map((a) => ({
account_number: a.account_number,
account_name: a.account_name,
is_system_account: a.is_system_account,
usage_count: usageByAccount.get(a.account_number) ?? 0,
}))
return NextResponse.json({ data: { deletable, used } })
}
// Execute: intersect the requested selection with the freshly computed
// deletable set — guards are re-verified here, not trusted from the
// preview the client saw.
const requested = [...new Set(account_numbers ?? [])]
const deletableSet = new Set(accounts.filter(isDeletable).map((a) => a.account_number))
const existingSet = new Set(accounts.map((a) => a.account_number))
const toDelete = requested.filter((n) => deletableSet.has(n))
const skipped = requested.filter((n) => existingSet.has(n) && !deletableSet.has(n))
const notFound = requested.filter((n) => !existingSet.has(n))
for (let i = 0; i < toDelete.length; i += DELETE_CHUNK_SIZE) {
const chunk = toDelete.slice(i, i + DELETE_CHUNK_SIZE)
const { error: deleteError } = await supabase
.from('chart_of_accounts')
.delete()
.eq('company_id', companyId)
.eq('is_system_account', false)
.in('account_number', chunk)
if (deleteError) {
// Report what was already deleted so the UI can refresh honestly.
return NextResponse.json(
{
error: deleteError.message,
data: { deleted: toDelete.slice(0, i), skipped, not_found: notFound },
},
{ status: 500 },
)
}
}
log.info('unused accounts pruned', {
deleted: toDelete.length,
skipped: skipped.length,
notFound: notFound.length,
})
return NextResponse.json({
data: { deleted: toDelete, skipped, not_found: notFound },
})
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to prune accounts' },
{ status: 500 },
)
}
},
{ requireWrite: true },
)
@@ -1,8 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/bookkeeping/accounts/reference
@@ -10,23 +9,19 @@ import { requireCompanyId } from '@/lib/company/context'
* Returns the full BAS reference catalog merged with the user's activation status.
* Each reference account includes: is_activated (exists in user's chart), is_active, is_system_account, is_custom.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const GET = withRouteContext('bookkeeping.accounts.reference', async (_request, ctx) => {
const { supabase, companyId } = ctx
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
// Paginated with a stable unique order — a full-BAS chart exceeds the
// 1000-row page size, and unordered .range() paging can duplicate or skip
// rows on page boundaries (see fetch-all.ts ordering invariant).
try {
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, is_active, is_system_account')
.eq('company_id', companyId)
.order('account_number', { ascending: true })
.range(from, to)
)
@@ -62,4 +57,4 @@ export async function GET() {
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
}
}
})
+68 -64
View File
@@ -1,24 +1,28 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { z } from 'zod'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Response shapes are legacy `{ data }` / `{ error: string }` — several pages
// (import, supplier-invoices, article form) consume the list directly.
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const ListQuerySchema = z.object({
class: z.coerce.number().int().min(1).max(8).optional(),
active: z.enum(['true', 'false']).optional(),
})
const companyId = await requireCompanyId(supabase, user.id)
export const GET = withRouteContext('bookkeeping.accounts.list', async (request, ctx) => {
const { supabase, companyId, log } = ctx
const { searchParams } = new URL(request.url)
const accountClass = searchParams.get('class')
const activeOnly = searchParams.get('active') !== 'false'
const validated = validateQuery(request, ListQuerySchema, {
log,
operation: 'bookkeeping.accounts.list',
})
if (!validated.success) return validated.response
const accountClass = validated.data.class
const activeOnly = validated.data.active !== 'false'
try {
const data = await fetchAllRows(({ from, to }) => {
@@ -32,8 +36,8 @@ export async function GET(request: Request) {
query = query.eq('is_active', true)
}
if (accountClass) {
query = query.eq('account_class', parseInt(accountClass))
if (accountClass !== undefined) {
query = query.eq('account_class', accountClass)
}
return query.range(from, to)
@@ -41,57 +45,57 @@ export async function GET(request: Request) {
return NextResponse.json({ data })
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch accounts' },
{ status: 500 },
)
}
}
})
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const POST = withRouteContext(
'bookkeeping.accounts.create',
async (request, ctx) => {
const { supabase, companyId, user, log } = ctx
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const validation = await validateBody(request, CreateAccountSchema)
if (!validation.success) return validation.response
const body = validation.data
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('chart_of_accounts')
.insert({
user_id: user.id,
company_id: companyId,
account_number: body.account_number,
account_name: body.account_name,
account_class: parseInt(body.account_number[0]),
account_group: body.account_number.substring(0, 2),
account_type: body.account_type,
normal_balance: body.normal_balance,
plan_type: body.plan_type || 'k1',
is_system_account: false,
description: body.description || null,
default_vat_code: body.default_vat_code || null,
sru_code: body.sru_code || null,
sort_order: parseInt(body.account_number),
const validation = await validateBody(request, CreateAccountSchema, {
log,
operation: 'bookkeeping.accounts.create',
})
.select()
.single()
if (!validation.success) return validation.response
const body = validation.data
if (error) {
if (error.code === '23505') {
return NextResponse.json(
{ error: `Kontonummer ${body.account_number} finns redan i din kontoplan.` },
{ status: 409 },
)
const { data, error } = await supabase
.from('chart_of_accounts')
.insert({
user_id: user.id,
company_id: companyId,
account_number: body.account_number,
account_name: body.account_name,
account_class: parseInt(body.account_number[0]),
account_group: body.account_number.substring(0, 2),
account_type: body.account_type,
normal_balance: body.normal_balance,
plan_type: body.plan_type || 'k1',
is_system_account: false,
description: body.description || null,
default_vat_code: body.default_vat_code || null,
sru_code: body.sru_code || null,
sort_order: parseInt(body.account_number),
})
.select()
.single()
if (error) {
if (error.code === '23505') {
return NextResponse.json(
{ error: `Kontonummer ${body.account_number} finns redan i din kontoplan.` },
{ status: 409 },
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
// GET /api/bookkeeping/accounts/usage — per-account posting counts for the
// active company, from the get_account_usage_counts RPC. Accounts that have
// never been posted to are absent from the result; that absence is the
// "unused" signal the kontoplan UI and the prune flow key on.
//
// Response shapes are legacy `{ data }` / `{ error: string }` — consumed by
// the kontoplan UI alongside the sibling account routes.
export const GET = withRouteContext('bookkeeping.accounts.usage', async (_request, ctx) => {
const { supabase, companyId } = ctx
const { data, error } = await supabase.rpc('get_account_usage_counts', {
p_company_id: companyId,
})
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: data ?? [] })
})
@@ -0,0 +1,66 @@
/**
* Tests for GET /api/bookkeeping/accruals — status filter validation and the
* due_count derivation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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'),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { GET } from '../route'
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
})
describe('GET /api/bookkeeping/accruals', () => {
it('returns 400 for an unknown status filter', async () => {
const req = createMockRequest('/api/bookkeeping/accruals', {
searchParams: { status: 'garbage' },
})
const { status } = await parseJsonResponse(await GET(req, routeParams))
expect(status).toBe(400)
})
it('lists schedules and counts due pending installments', async () => {
enqueue({
data: [
{
id: 'sched-1',
status: 'active',
created_at: '2026-01-01T00:00:00Z',
installments: [
{ id: 'i1', period_month: '2020-01-01', status: 'pending' },
{ id: 'i2', period_month: '2099-01-01', status: 'pending' },
{ id: 'i3', period_month: '2020-02-01', status: 'posted' },
],
},
],
})
const { status, body } = await parseJsonResponse<{ data: unknown[]; due_count: number }>(
await GET(createMockRequest('/api/bookkeeping/accruals'), routeParams)
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
// Only the past-month pending installment counts as due.
expect(body.due_count).toBe(1)
})
})
+12 -2
View File
@@ -1,12 +1,18 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
import type { AccrualSchedule, AccrualScheduleInstallment } from '@/types'
ensureInitialized()
const ListQuerySchema = z.object({
status: z.enum(['active', 'completed', 'cancelled', 'all']).default('active'),
})
/**
* GET /api/bookkeeping/accruals?status=active|completed|cancelled|all
*
@@ -19,8 +25,12 @@ export const GET = withRouteContext(
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') || 'active'
const validated = validateQuery(request, ListQuerySchema, {
log,
operation: 'accruals.list',
})
if (!validated.success) return validated.response
const { status } = validated.data
let query = supabase
.from('accrual_schedules')
@@ -6,6 +6,7 @@ vi.mock('@/lib/supabase/server', () => ({
}))
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 }),
@@ -0,0 +1,88 @@
/**
* Tests for POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner —
* input-bound validation. The schablonintäkt rate feeds the avsättning cap
* base (IL 30 kap 25 % limit), so an unbounded rate would let a caller
* inflate the legal ceiling; these tests lock the bounds in.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
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),
}))
import { POST } from '../route'
const idParams = { params: Promise.resolve({ id: 'period-1' }) }
function post(body: unknown) {
return POST(
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/bokslutsdispositioner', {
method: 'POST',
body,
}),
idParams,
)
}
beforeEach(() => {
vi.clearAllMocks()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await post({ items: [{ kind: 'bolagsskatt' }] })
expect(res.status).toBe(401)
})
it('rejects an inflated schablonintäkt rate (cap-base attack) with 400', async () => {
const { status } = await parseJsonResponse(
await post({
items: [{ kind: 'periodiseringsfond_avsattning', schablonintaktRate: 100 }],
}),
)
expect(status).toBe(400)
})
it('rejects a negative desiredAmount with 400', async () => {
const { status } = await parseJsonResponse(
await post({
items: [{ kind: 'periodiseringsfond_avsattning', desiredAmount: -50000 }],
}),
)
expect(status).toBe(400)
})
it('rejects negative återföring amounts with 400', async () => {
const { status } = await parseJsonResponse(
await post({
items: [{ kind: 'periodiseringsfond_ateforing', returns: { '2129': -10000 } }],
}),
)
expect(status).toBe(400)
})
it('rejects an empty items array with 400', async () => {
const { status } = await parseJsonResponse(await post({ items: [] }))
expect(status).toBe(400)
})
})
@@ -103,14 +103,16 @@ const ItemSchema = z.discriminatedUnion('kind', [
kind: z.literal('periodiseringsfond_avsattning'),
/** Optional override for the SLR-based schablonintäkt rate; defaults to
* the server-side constant. Used both to compute the cap base and to
* feed back into bolagsskatt's adjustment if present in the same batch. */
schablonintaktRate: z.number().optional(),
desiredAmount: z.number().optional(),
* feed back into bolagsskatt's adjustment if present in the same batch.
* Bounded to a sane range — an inflated rate would inflate the cap base
* and let the caller exceed the legal 25 % avsättning limit (IL 30 kap). */
schablonintaktRate: z.number().min(0).max(0.2).optional(),
desiredAmount: z.number().positive().optional(),
}),
z.object({
kind: z.literal('periodiseringsfond_ateforing'),
returns: z.record(z.string(), z.number()).default({}),
schablonintaktRate: z.number().default(DEFAULT_SCHABLONINTAKT_RATE),
returns: z.record(z.string(), z.number().nonnegative()).default({}),
schablonintaktRate: z.number().min(0).max(0.2).default(DEFAULT_SCHABLONINTAKT_RATE),
}),
z.object({
kind: z.literal('overavskrivningar'),
@@ -0,0 +1,79 @@
/**
* Tests for POST /api/bookkeeping/fiscal-periods/[id]/close.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
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/core/bookkeeping/period-service', () => ({
closePeriod: vi.fn(),
}))
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
import { POST } from '../route'
const mockClosePeriod = vi.mocked(closePeriod)
const idParams = { params: Promise.resolve({ id: 'period-1' }) }
beforeEach(() => {
vi.clearAllMocks()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('POST /api/bookkeeping/fiscal-periods/[id]/close', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
expect(res.status).toBe(401)
})
it('returns 403 when the caller lacks write permission', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
})
const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
expect(res.status).toBe(403)
expect(mockClosePeriod).not.toHaveBeenCalled()
})
it('maps a service refusal to 400 with the message', async () => {
mockClosePeriod.mockRejectedValue(new Error('Period contains draft entries'))
const { status, body } = await parseJsonResponse<{ error: string }>(
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
)
expect(status).toBe(400)
expect(body.error).toBe('Period contains draft entries')
})
it('closes the period on the happy path', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockClosePeriod.mockResolvedValue({ id: 'period-1', is_closed: true } as any)
const { status, body } = await parseJsonResponse<{ data: { is_closed: boolean } }>(
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
)
expect(status).toBe(200)
expect(body.data.is_closed).toBe(true)
expect(mockClosePeriod).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'period-1')
})
})
@@ -1,33 +1,25 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Response shapes are legacy `{ error: string }` — kept for the year-end UI.
// closePeriod throws plain Errors for every refusal (period not found, drafts
// remaining, already closed); they all map to 400 as before.
export const POST = withRouteContext(
'period.close',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId } = ctx
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)
try {
const period = await closePeriod(supabase, companyId, user.id, id)
return NextResponse.json({ data: period })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to close period' },
{ status: 400 }
)
}
}
try {
const period = await closePeriod(supabase, companyId, user.id, id)
return NextResponse.json({ data: period })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to close period' },
{ status: 400 }
)
}
},
{ requireWrite: true },
)
@@ -5,6 +5,7 @@ vi.mock('@/lib/supabase/server', () => ({
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server'
@@ -1,42 +1,34 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const GET = withRouteContext(
'period.entry_count',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId } = ctx
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
const companyId = await requireCompanyId(supabase, user.id)
if (fetchError || !period) {
return NextResponse.json({ error: 'Räkenskapsår hittades inte' }, { status: 404 })
}
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
const { count, error: countError } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
.in('status', ['posted', 'reversed'])
if (fetchError || !period) {
return NextResponse.json({ error: 'Räkenskapsår hittades inte' }, { status: 404 })
}
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 })
}
const { count, error: countError } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
.in('status', ['posted', 'reversed'])
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 })
}
return NextResponse.json({ data: { posted_count: count ?? 0 } })
}
return NextResponse.json({ data: { posted_count: count ?? 0 } })
},
)
@@ -1,9 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { validatePeriodDuration, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
const UpdateFiscalPeriodSchema = z.object({
@@ -12,22 +10,14 @@ const UpdateFiscalPeriodSchema = z.object({
period_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Slutdatum måste vara i format ÅÅÅÅ-MM-DD').optional(),
})
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
// Response shapes are legacy `{ error: string }` (Swedish) — the fiscal-year
// settings UI renders them directly. Only the auth/company layer was moved
// into withRouteContext.
export const PATCH = withRouteContext(
'period.update',
async (request, ctx, { 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)
const { supabase, companyId } = ctx
const validation = await validateBody(request, UpdateFiscalPeriodSchema)
if (!validation.success) return validation.response
@@ -166,4 +156,6 @@ export async function PATCH(
}
return NextResponse.json({ data: updated })
}
},
{ requireWrite: true },
)
@@ -6,6 +6,7 @@ vi.mock('@/lib/supabase/server', () => ({
}))
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 }),
@@ -0,0 +1,80 @@
/**
* Tests for GET /api/bookkeeping/fiscal-periods/period-status.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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'),
}))
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
resolvePeriodStatusForDate: vi.fn(),
}))
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { GET } from '../route'
const mockResolve = vi.mocked(resolvePeriodStatusForDate)
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
})
describe('GET /api/bookkeeping/fiscal-periods/period-status', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = createMockRequest('/api/bookkeeping/fiscal-periods/period-status', {
searchParams: { date: '2026-01-15' },
})
const res = await GET(req, routeParams)
expect(res.status).toBe(401)
})
it('returns 400 for a malformed date', async () => {
const req = createMockRequest('/api/bookkeeping/fiscal-periods/period-status', {
searchParams: { date: '15/01/2026' },
})
const { status } = await parseJsonResponse(await GET(req, routeParams))
expect(status).toBe(400)
expect(mockResolve).not.toHaveBeenCalled()
})
it('returns the status with the covering period name', async () => {
mockResolve.mockResolvedValue({
status: 'open',
period_id: 'period-1',
lock_date: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
enqueue({ data: { name: 'Räkenskapsår 2026' } })
const req = createMockRequest('/api/bookkeeping/fiscal-periods/period-status', {
searchParams: { date: '2026-01-15' },
})
const { status, body } = await parseJsonResponse<{
data: { status: string; period_name: string }
}>(await GET(req, routeParams))
expect(status).toBe(200)
expect(body.data.status).toBe('open')
expect(body.data.period_name).toBe('Räkenskapsår 2026')
expect(mockResolve).toHaveBeenCalledWith(expect.anything(), 'company-1', '2026-01-15')
})
})
@@ -1,7 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/bookkeeping/fiscal-periods/period-status?date=YYYY-MM-DD
@@ -11,20 +10,14 @@ import { requireCompanyId } from '@/lib/company/context'
* the covering period's label so the UI can show "flyttas till <år>" before a
* write is attempted. Mirrors resolvePeriodStatusForDate / the DB triggers.
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
export const GET = withRouteContext('period.status_for_date', async (request, ctx) => {
const { supabase, companyId } = ctx
const date = new URL(request.url).searchParams.get('date')
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'Ogiltigt datum (förväntat ÅÅÅÅ-MM-DD)' }, { status: 400 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const status = await resolvePeriodStatusForDate(supabase, companyId, date)
@@ -58,4 +51,4 @@ export async function GET(request: Request) {
{ status: 500 }
)
}
}
})
+22 -29
View File
@@ -1,24 +1,15 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/bookkeeping/fiscal-periods')
// Response shapes are legacy `{ error: string }` (plus one envelope code for
// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI.
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
export const GET = withRouteContext('period.list', async (_request, ctx) => {
const { supabase, companyId } = ctx
const { data, error } = await supabase
.from('fiscal_periods')
@@ -31,20 +22,12 @@ export async function GET() {
}
return NextResponse.json({ data })
}
})
export async function POST(request: Request) {
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 POST = withRouteContext(
'period.create',
async (request, ctx) => {
const { supabase, companyId, user, log } = ctx
const validation = await validateBody(request, CreateFiscalPeriodSchema)
if (!validation.success) return validation.response
@@ -231,13 +214,23 @@ export async function POST(request: Request) {
const isPrepend = body.period_end < earliest.period_start
const periodToRelink = isPrepend ? earliest : successor
if (periodToRelink) {
await supabase
const { error: relinkError } = await supabase
.from('fiscal_periods')
.update({ previous_period_id: data.id })
.eq('id', periodToRelink.id)
.eq('company_id', companyId)
if (relinkError) {
// The period WAS created — don't fail the request, but a broken
// continuity chain (BFNAR 2013:2) must never be silent.
log.error('failed to relink continuity chain after period create', relinkError, {
createdPeriodId: data.id,
relinkPeriodId: periodToRelink.id,
})
}
}
}
return NextResponse.json({ data })
}
},
{ requireWrite: true },
)
@@ -1,20 +1,10 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.chain',
async (_request, { supabase, companyId }, { params }) => {
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 companyId = await requireCompanyId(supabase, user.id)
// Fetch the requested entry with lines
const { data: entry, error } = await supabase
@@ -127,4 +117,5 @@ export async function GET(
}
return NextResponse.json({ data: { entry, chain, is_last_in_series: isLastInSeries } })
}
},
)
@@ -1,7 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
@@ -9,22 +7,10 @@ const SetNoDocSchema = z.object({
reason: z.string().trim().max(200).nullable().optional(),
})
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.no_doc_required.set',
async (request, { supabase, companyId, user }, { params }) => {
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)
const result = await validateBody(request, SetNoDocSchema)
if (!result.success) return result.response
@@ -57,24 +43,14 @@ export async function POST(
}
return NextResponse.json({ data: { exempted: true } })
}
},
{ requireWrite: true },
)
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.no_doc_required.unset',
async (_request, { supabase, companyId }, { params }) => {
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)
// Authorization is company-scoped, not user-scoped: any non-viewer member
// of the active company may revoke any exemption in that company. The flag
@@ -92,4 +68,6 @@ export async function DELETE(
}
return NextResponse.json({ data: { exempted: false } })
}
},
{ requireWrite: true },
)
@@ -0,0 +1,81 @@
/**
* Tests for PATCH /api/bookkeeping/journal-entries/[id]/notes.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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),
}))
import { PATCH } from '../route'
const idParams = { params: Promise.resolve({ id: 'entry-1' }) }
function patch(body: unknown) {
return PATCH(
createMockRequest('/api/bookkeeping/journal-entries/entry-1/notes', {
method: 'PATCH',
body,
}),
idParams,
)
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('PATCH /api/bookkeeping/journal-entries/[id]/notes', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await patch({ notes: 'hej' })
expect(res.status).toBe(401)
})
it('rejects an over-long note with 400', async () => {
const { status } = await parseJsonResponse(await patch({ notes: 'x'.repeat(2001) }))
expect(status).toBe(400)
})
it('returns 404 instead of phantom success when no row matches', async () => {
enqueue({ data: null }) // update matched zero rows
const { status, body } = await parseJsonResponse<{ error: string }>(
await patch({ notes: 'En anteckning' })
)
expect(status).toBe(404)
expect(body.error).toBe('Verifikationen hittades inte.')
})
it('updates the note on the happy path', async () => {
enqueue({ data: { id: 'entry-1' } })
const { status, body } = await parseJsonResponse<{ data: { updated: boolean } }>(
await patch({ notes: 'En anteckning' })
)
expect(status).toBe(200)
expect(body.data.updated).toBe(true)
})
})
@@ -1,7 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
@@ -9,35 +7,35 @@ const UpdateNotesSchema = z.object({
notes: z.string().max(2000).nullable(),
})
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Notes are annotation metadata alongside the verifikat (not räkenskaps-
// information) — the immutability trigger governs what may change on posted
// entries; this route just scopes and validates.
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.notes',
async (request, { supabase, companyId }, { params }) => {
const { id } = await params
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await validateBody(request, UpdateNotesSchema)
if (!result.success) return result.response
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const { data, error } = await supabase
.from('journal_entries')
.update({ notes: result.data.notes })
.eq('id', id)
.eq('company_id', companyId)
.select('id')
.maybeSingle()
const companyId = await requireCompanyId(supabase, user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
// Zero rows = the entry doesn't exist in this company — report it instead
// of a phantom success.
if (!data) {
return NextResponse.json({ error: 'Verifikationen hittades inte.' }, { status: 404 })
}
const result = await validateBody(request, UpdateNotesSchema)
if (!result.success) return result.response
const { error } = await supabase
.from('journal_entries')
.update({ notes: result.data.notes })
.eq('id', id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
return NextResponse.json({ data: { updated: true } })
}
return NextResponse.json({ data: { updated: true } })
},
{ requireWrite: true },
)
@@ -1,7 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -17,50 +14,30 @@ const logger = createLogger('journal-entries')
ensureInitialized()
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.get',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
const companyId = await requireCompanyId(supabase, user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 404 })
}
const { data, error } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
return NextResponse.json({ data })
},
)
if (error) {
return NextResponse.json({ error: error.message }, { status: 404 })
}
return NextResponse.json({ data })
}
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 }> }>(
'bookkeeping.journal_entry.delete',
async (_request, { supabase, companyId, user }, { params }) => {
const { id } = await params
// Read source_type/source_id BEFORE deleting so we can revert the linked
// invoice/supplier_invoice status afterwards. The GL row gets cancelled by
@@ -107,13 +84,14 @@ export async function DELETE(
})
return NextResponse.json({ data })
}
},
{ requireWrite: true },
)
/**
* PATCH: edit a DRAFT verifikat in place (header + lines). Only drafts are
* editable; updateDraftEntry rejects committed entries with a 409, and the DB
* immutability trigger is the backstop. Uses withRouteContext (MFA + write gate):
* the GET/DELETE above predate that wrapper and are intentionally left as-is.
* immutability trigger is the backstop.
*/
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.update',
+14 -26
View File
@@ -1,25 +1,19 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createDraftEntry, createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
ensureInitialized()
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Query params are hand-parsed with per-param clamping/regex validation (see
// each param's comment) rather than a Zod schema; response shapes are legacy
// `{ data, count }` / `{ error: string }` for the verifikat list UI.
export const GET = withRouteContext('bookkeeping.journal_entries.list', async (request, ctx) => {
const { supabase, companyId } = ctx
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
@@ -187,20 +181,12 @@ export async function GET(request: Request) {
}
return NextResponse.json({ data, count })
}
})
export async function POST(request: Request) {
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 POST = withRouteContext(
'bookkeeping.journal_entries.create',
async (request, ctx) => {
const { supabase, companyId, user } = ctx
const validation = await validateBody(request, CreateJournalEntrySchema)
if (!validation.success) return validation.response
@@ -222,4 +208,6 @@ export async function POST(request: Request) {
{ status: 400 }
)
}
}
},
{ requireWrite: true },
)
@@ -1,22 +1,17 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction } from '@/types'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const POST = withRouteContext('mapping_rules.evaluate', async (request, ctx) => {
const { supabase, companyId, log } = ctx
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, EvaluateMappingRulesSchema)
const validation = await validateBody(request, EvaluateMappingRulesSchema, {
log,
operation: 'mapping_rules.evaluate',
})
if (!validation.success) return validation.response
const body = validation.data
@@ -37,6 +32,8 @@ export async function POST(request: Request) {
transaction = data as Transaction
} else {
// Schema-validated (amount required, passthrough for optional signal
// fields) — the mapping engine only reads the fields it knows.
transaction = body as unknown as Transaction
}
@@ -49,4 +46,4 @@ export async function POST(request: Request) {
{ status: 500 }
)
}
}
})
+46 -56
View File
@@ -1,19 +1,10 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
export const GET = withRouteContext('mapping_rules.list', async (_request, ctx) => {
const { supabase, companyId } = ctx
const { data, error } = await supabase
.from('mapping_rules')
@@ -27,52 +18,51 @@ export async function GET() {
}
return NextResponse.json({ data })
}
})
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const POST = withRouteContext(
'mapping_rules.create',
async (request, ctx) => {
const { supabase, companyId, user, log } = ctx
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)
const result = await validateBody(request, CreateMappingRuleSchema)
if (!result.success) return result.response
const body = result.data
const { data, error } = await supabase
.from('mapping_rules')
.insert({
user_id: user.id,
company_id: companyId,
rule_name: body.rule_name,
rule_type: body.rule_type,
priority: body.priority || 10,
mcc_codes: body.mcc_codes || null,
merchant_pattern: body.merchant_pattern || null,
description_pattern: body.description_pattern || null,
amount_min: body.amount_min || null,
amount_max: body.amount_max || null,
debit_account: body.debit_account,
credit_account: body.credit_account,
vat_treatment: body.vat_treatment || null,
risk_level: body.risk_level || 'NONE',
default_private: body.default_private || false,
requires_review: body.requires_review || false,
confidence_score: body.confidence_score || 0.9,
const result = await validateBody(request, CreateMappingRuleSchema, {
log,
operation: 'mapping_rules.create',
})
.select()
.single()
if (!result.success) return result.response
const body = result.data
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const { data, error } = await supabase
.from('mapping_rules')
.insert({
user_id: user.id,
company_id: companyId,
rule_name: body.rule_name,
rule_type: body.rule_type,
// ?? not || — the schema allows 0 for priority and confidence_score,
// and || would silently coerce those to the defaults.
priority: body.priority ?? 10,
mcc_codes: body.mcc_codes ?? null,
merchant_pattern: body.merchant_pattern ?? null,
description_pattern: body.description_pattern ?? null,
amount_min: body.amount_min ?? null,
amount_max: body.amount_max ?? null,
debit_account: body.debit_account,
credit_account: body.credit_account,
vat_treatment: body.vat_treatment ?? null,
risk_level: body.risk_level ?? 'NONE',
default_private: body.default_private ?? false,
requires_review: body.requires_review ?? false,
confidence_score: body.confidence_score ?? 0.9,
})
.select()
.single()
return NextResponse.json({ data })
}
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
@@ -0,0 +1,74 @@
/**
* Tests for GET /api/bookkeeping/no-doc-required the exemption-set list.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
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'),
}))
import { GET } from '../route'
const routeParams = { params: Promise.resolve({}) }
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
const calls: { method: string; args: unknown[] }[] = []
let idx = 0
const makeBuilder = () => {
const result = results[idx++] ?? { data: null, error: null }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: any = {}
for (const m of ['select', 'eq', 'order', 'range']) {
b[m] = (...args: unknown[]) => {
calls.push({ method: m, args })
return b
}
}
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null, count: null })
return b
}
return { supabase: { from: () => makeBuilder() }, calls }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/bookkeeping/no-doc-required', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(createMockRequest('/api/bookkeeping/no-doc-required'), routeParams)
expect(res.status).toBe(401)
})
it('lists exemptions with a stable paging order', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: [{ journal_entry_id: 'e1', reason: 'SIE-import' }] },
])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await GET(createMockRequest('/api/bookkeeping/no-doc-required'), routeParams)
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
// Paging-stability regression guard (fetch-all.ts ordering invariant).
expect(calls.filter((c) => c.method === 'order').map((c) => c.args[0])).toContain(
'journal_entry_id'
)
})
})
+7 -12
View File
@@ -1,6 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
@@ -9,24 +8,20 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
* - exclude exempted entries from the "Saknade underlag" filter
* - show a muted "no doc needed" indicator instead of the warning triangle
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
export const GET = withRouteContext('journal_entry.no_doc_required.list', async (_request, ctx) => {
const { supabase, companyId } = ctx
// Stable unique order for .range() paging — bulk exemption after a large
// migration can push this table past the 1000-row page size.
const rows = await fetchAllRows<{ journal_entry_id: string; reason: string | null }>(
({ from, to }) =>
supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id, reason')
.eq('company_id', companyId)
.order('journal_entry_id', { ascending: true })
.range(from, to)
)
return NextResponse.json({ data: rows })
}
})
+70 -66
View File
@@ -1,21 +1,19 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Voucher gap detection + explanations (BFNAR 2013:2 — gaps in voucher
// sequences must be documented). Response shapes are legacy `{ data }` /
// `{ error: string }`.
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
export const GET = withRouteContext('voucher_gaps.list', async (request, ctx) => {
const { supabase, companyId, log } = ctx
const companyId = await requireCompanyId(supabase, user.id)
const validation = validateQuery(request, VoucherGapQuerySchema)
const validation = validateQuery(request, VoucherGapQuerySchema, {
log,
operation: 'voucher_gaps.list',
})
if (!validation.success) return validation.response
const { fiscal_period_id, voucher_series } = validation.data
@@ -30,7 +28,11 @@ export async function GET(request: Request) {
seriesQuery = seriesQuery.eq('voucher_series', voucher_series)
}
const { data: seriesRows } = await seriesQuery
const { data: seriesRows, error: seriesError } = await seriesQuery
if (seriesError) {
log.error('voucher series lookup failed', seriesError)
return NextResponse.json({ error: seriesError.message }, { status: 500 })
}
if (!seriesRows || seriesRows.length === 0) {
return NextResponse.json({
@@ -53,15 +55,20 @@ export async function GET(request: Request) {
p_series: row.voucher_series,
})
if (!gapsError && gaps && gaps.length > 0) {
for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) {
allGaps.push({
series: row.voucher_series,
gap_start: gap.gap_start,
gap_end: gap.gap_end,
explanation: null,
})
}
// A failing detection MUST surface — silently dropping the series would
// render "no gaps" on a compliance view when the check didn't run.
if (gapsError) {
log.error('detect_voucher_gaps failed', gapsError, { series: row.voucher_series })
return NextResponse.json({ error: gapsError.message }, { status: 500 })
}
for (const gap of (gaps ?? []) as Array<{ gap_start: number; gap_end: number }>) {
allGaps.push({
series: row.voucher_series,
gap_start: gap.gap_start,
gap_end: gap.gap_end,
explanation: null,
})
}
}
@@ -102,52 +109,49 @@ export async function GET(request: Request) {
unexplainedGaps: unexplained,
},
})
}
})
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const POST = withRouteContext(
'voucher_gaps.explain',
async (request, ctx) => {
const { supabase, companyId, user, log } = ctx
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const validation = await validateBody(request, SaveGapExplanationSchema, {
log,
operation: 'voucher_gaps.explain',
})
if (!validation.success) return validation.response
const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, SaveGapExplanationSchema)
if (!validation.success) return validation.response
const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data
// Upsert explanation (RLS enforces owner/admin role)
const { data, error } = await supabase
.from('voucher_gap_explanations')
.upsert(
{
company_id: companyId,
user_id: user.id,
fiscal_period_id,
voucher_series,
gap_start,
gap_end,
explanation,
},
{ onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' }
)
.select()
.single()
if (error) {
if (error.code === '42501') {
return NextResponse.json(
{ error: 'Only company owners and admins can document gap explanations' },
{ status: 403 }
// Upsert explanation (RLS enforces owner/admin role)
const { data, error } = await supabase
.from('voucher_gap_explanations')
.upsert(
{
company_id: companyId,
user_id: user.id,
fiscal_period_id,
voucher_series,
gap_start,
gap_end,
explanation,
},
{ onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' }
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
.select()
.single()
return NextResponse.json({ data })
}
if (error) {
if (error.code === '42501') {
return NextResponse.json(
{ error: 'Only company owners and admins can document gap explanations' },
{ status: 403 }
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
+4 -1
View File
@@ -1,6 +1,9 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/calendar/feed-token')
// In-memory rate limiting: token -> { count, resetAt }
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
@@ -141,7 +144,7 @@ export async function GET(
},
})
} catch (error) {
console.error('Error generating ICS feed:', error)
log.error('Error generating ICS feed', error as Error, { feedId: feed.id })
return new NextResponse('Failed to generate calendar feed', { status: 500 })
}
}
@@ -0,0 +1,94 @@
/**
* Tests for /api/calendar/feed (settings CRUD).
*
* The PUT hardening matters most: the previous implementation passed the raw
* JSON body into .update(), letting a caller set feed_token (token fixation
* on a public URL). The strict schema must reject any key beyond the two
* content toggles.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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),
}))
import { GET, PUT } from '../route'
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('GET /api/calendar/feed', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(createMockRequest('/api/calendar/feed'), routeParams)
expect(res.status).toBe(401)
})
it('returns the feed with generated URLs', async () => {
enqueue({ data: { id: 'feed-1', feed_token: 'tok-123', include_invoices: true } })
const { status, body } = await parseJsonResponse<{
data: { webcalUrl: string; httpsUrl: string }
}>(await GET(createMockRequest('/api/calendar/feed'), routeParams))
expect(status).toBe(200)
expect(body.data.httpsUrl).toContain('/api/calendar/feed/tok-123')
expect(body.data.webcalUrl).toMatch(/^webcal:\/\//)
})
})
describe('PUT /api/calendar/feed', () => {
it('rejects an attempt to set feed_token (token fixation) with 400', async () => {
const req = createMockRequest('/api/calendar/feed', {
method: 'PUT',
body: { feed_token: '11111111-1111-1111-1111-111111111111' },
})
const { status } = await parseJsonResponse(await PUT(req, routeParams))
expect(status).toBe(400)
})
it('rejects an empty body with 400', async () => {
const req = createMockRequest('/api/calendar/feed', { method: 'PUT', body: {} })
const { status } = await parseJsonResponse(await PUT(req, routeParams))
expect(status).toBe(400)
})
it('updates the content toggles', async () => {
enqueue({ data: { id: 'feed-1', feed_token: 'tok-123', include_invoices: false } })
const req = createMockRequest('/api/calendar/feed', {
method: 'PUT',
body: { include_invoices: false },
})
const { status, body } = await parseJsonResponse<{ data: { include_invoices: boolean } }>(
await PUT(req, routeParams)
)
expect(status).toBe(200)
expect(body.data.include_invoices).toBe(false)
})
})
+113 -143
View File
@@ -1,23 +1,37 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { UpdateCalendarFeedInput } from '@/types'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
// Only the two content toggles are user-settable. Strict: the previous
// implementation passed the raw JSON body into .update(), which would have
// let a caller set feed_token (token fixation on a public URL), expires_at,
// or access_count.
const UpdateFeedSchema = z
.object({
include_tax_deadlines: z.boolean().optional(),
include_invoices: z.boolean().optional(),
})
.strict()
.refine(
(v) => v.include_tax_deadlines !== undefined || v.include_invoices !== undefined,
{ message: 'Nothing to update' },
)
function feedUrls(feedToken: string) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
return {
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feedToken}`,
httpsUrl: `${baseUrl}/api/calendar/feed/${feedToken}`,
}
}
/**
* GET /api/calendar/feed
* Get current user's calendar feed settings
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
export const GET = withRouteContext('calendar_feed.get', async (_request, ctx) => {
const { supabase, companyId } = ctx
const { data: feed, error } = await supabase
.from('calendar_feeds')
@@ -30,167 +44,123 @@ export async function GET() {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Generate the feed URL
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
if (feed) {
return NextResponse.json({
data: {
...feed,
// Generate webcal:// URL for Apple Calendar
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
// Generate https:// URL for other calendars
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
},
data: { ...feed, ...feedUrls(feed.feed_token) },
})
}
return NextResponse.json({ data: null })
}
})
/**
* POST /api/calendar/feed
* Create a new calendar feed for the current user
*/
export async function POST() {
const supabase = await createClient()
export const POST = withRouteContext(
'calendar_feed.create',
async (_request, ctx) => {
const { supabase, companyId, user } = ctx
const { data: { user } } = await supabase.auth.getUser()
// Check if feed already exists
const { data: existingFeed } = await supabase
.from('calendar_feeds')
.select('id')
.eq('company_id', companyId)
.single()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (existingFeed) {
return NextResponse.json(
{ error: 'Calendar feed already exists' },
{ status: 409 }
)
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
// Create new feed
const { data: feed, error } = await supabase
.from('calendar_feeds')
.insert({
user_id: user.id,
company_id: companyId,
is_active: true,
include_tax_deadlines: true,
include_invoices: true,
})
.select()
.single()
const companyId = await requireCompanyId(supabase, user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Check if feed already exists
const { data: existingFeed } = await supabase
.from('calendar_feeds')
.select('id')
.eq('company_id', companyId)
.single()
if (existingFeed) {
return NextResponse.json(
{ error: 'Calendar feed already exists' },
{ status: 409 }
)
}
// Create new feed
const { data: feed, error } = await supabase
.from('calendar_feeds')
.insert({
user_id: user.id,
company_id: companyId,
is_active: true,
include_tax_deadlines: true,
include_invoices: true,
return NextResponse.json({
data: { ...feed, ...feedUrls(feed.feed_token) },
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
return NextResponse.json({
data: {
...feed,
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
},
})
}
},
{ requireWrite: true },
)
/**
* PUT /api/calendar/feed
* Update calendar feed settings
*/
export async function PUT(request: Request) {
const supabase = await createClient()
export const PUT = withRouteContext(
'calendar_feed.update',
async (request, ctx) => {
const { supabase, companyId, log } = ctx
const { data: { user } } = await supabase.auth.getUser()
const validation = await validateBody(request, UpdateFeedSchema, {
log,
operation: 'calendar_feed.update',
})
if (!validation.success) return validation.response
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: feed, error } = await supabase
.from('calendar_feeds')
.update(validation.data)
.eq('company_id', companyId)
.select()
.single()
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body: UpdateCalendarFeedInput = await request.json()
const { data: feed, error } = await supabase
.from('calendar_feeds')
.update(body)
.eq('company_id', companyId)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
return NextResponse.json({
data: {
...feed,
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
},
})
}
return NextResponse.json({
data: { ...feed, ...feedUrls(feed.feed_token) },
})
},
{ requireWrite: true },
)
/**
* DELETE /api/calendar/feed
* Regenerate calendar feed token (invalidates old URL)
*/
export async function DELETE() {
const supabase = await createClient()
export const DELETE = withRouteContext(
'calendar_feed.rotate_token',
async (_request, ctx) => {
const { supabase, companyId } = ctx
const { data: { user } } = await supabase.auth.getUser()
// Generate a new token by updating with a new UUID
const { data: feed, error } = await supabase
.from('calendar_feeds')
.update({
feed_token: crypto.randomUUID(),
access_count: 0,
last_accessed_at: null,
})
.eq('company_id', companyId)
.select()
.single()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Generate a new token by updating with a new UUID
const { data: feed, error } = await supabase
.from('calendar_feeds')
.update({
feed_token: crypto.randomUUID(),
access_count: 0,
last_accessed_at: null,
return NextResponse.json({
data: { ...feed, ...feedUrls(feed.feed_token) },
})
.eq('company_id', companyId)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
return NextResponse.json({
data: {
...feed,
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
},
})
}
},
{ requireWrite: true },
)
+4 -12
View File
@@ -1,6 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
import { listForCompany } from '@/lib/cash-accounts/service'
/**
@@ -15,19 +14,12 @@ import { listForCompany } from '@/lib/cash-accounts/service'
* Query params:
* - enabled_only=true only accounts with enabled=true (default returns all)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) {
return NextResponse.json({ error: 'No company context' }, { status: 400 })
}
export const GET = withRouteContext('cash_accounts.list', async (request, ctx) => {
const { supabase, companyId } = ctx
const url = new URL(request.url)
const enabledOnly = url.searchParams.get('enabled_only') === 'true'
const accounts = await listForCompany(supabase, companyId, { enabledOnly })
return NextResponse.json({ data: accounts })
}
})
+14 -5
View File
@@ -25,10 +25,11 @@ const DeleteCompanySchema = z.object({
*
* Rules:
* - Only callers with role='owner' in company_members may delete.
* - The body must include confirm_name matching the company's display name.
* The UI shows company_settings.company_name (companies.name may be stale),
* so we validate against that, falling back to companies.name. Either value
* is accepted so the confirm gate never blocks a legitimate deletion.
* - The body must include confirm_name matching the company's display name
* exactly as the UI shows it: company_settings.company_name, falling back
* to companies.name only when no settings row exists. ONLY that single
* name is accepted (see step 3) accepting alternates would weaken the
* confirmation gate on an irreversible action.
* - Already-archived companies return 404 (treated as not found).
*/
export async function POST(
@@ -137,7 +138,9 @@ export async function POST(
// 6. Write audit log row. companies has no auto-audit trigger, so do it
// explicitly. Service client bypasses audit_log RLS (no INSERT policy).
await service.from('audit_log').insert({
// The archive already happened — don't fail the request, but an audit
// write failing on an irreversible action must never be silent.
const { error: auditError } = await service.from('audit_log').insert({
user_id: user.id,
company_id: companyId,
action: 'DELETE',
@@ -148,6 +151,12 @@ export async function POST(
new_state: { archived_at: archivedAt, archived_by: user.id },
description: `Company archived: ${company.name}`,
})
if (auditError) {
log.error('Failed to write audit_log row for company archive', {
companyId,
error: auditError.message,
})
}
// 7. Emit event
await eventBus.emit({
@@ -0,0 +1,89 @@
/**
* Tests for /api/company/current GET (cross-tab sync) and PATCH (K2/K3).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
const getActiveCompanyIdMock = vi.fn()
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: (...args: unknown[]) => getActiveCompanyIdMock(...args),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
import { GET, PATCH } from '../route'
const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
getActiveCompanyIdMock.mockResolvedValue('company-1')
})
describe('GET /api/company/current', () => {
it('returns 401 with no-store when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET()
expect(res.status).toBe(401)
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
})
it('returns null companyId when the user has no active company', async () => {
getActiveCompanyIdMock.mockResolvedValue(null)
const { status, body } = await parseJsonResponse<{ companyId: string | null }>(await GET())
expect(status).toBe(200)
expect(body.companyId).toBeNull()
})
})
describe('PATCH /api/company/current', () => {
it('rejects K3 for enskild firma with 400', async () => {
enqueue({ data: { entity_type: 'enskild_firma' } })
const req = createMockRequest('/api/company/current', {
method: 'PATCH',
body: { accounting_framework: 'k3' },
})
const { status, body } = await parseJsonResponse<{ error: string }>(
await PATCH(req, routeParams)
)
expect(status).toBe(400)
expect(body.error).toContain('aktiebolag')
})
it('updates the framework for an aktiebolag', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // entity check
enqueue({ data: { id: 'company-1', accounting_framework: 'k3', entity_type: 'aktiebolag' } }) // update
enqueue({ data: null }) // K3 latent-tax account upsert
const req = createMockRequest('/api/company/current', {
method: 'PATCH',
body: { accounting_framework: 'k3' },
})
const { status, body } = await parseJsonResponse<{
data: { accounting_framework: string }
}>(await PATCH(req, routeParams))
expect(status).toBe(200)
expect(body.data.accounting_framework).toBe('k3')
})
})
+18 -26
View File
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { getActiveCompanyId, requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { getActiveCompanyId } from '@/lib/company/context'
import { requireAuth } from '@/lib/auth/require-auth'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { AccountingFrameworkSchema } from '@/lib/api/schemas'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
@@ -27,20 +27,17 @@ const K3_LATENT_TAX_ACCOUNTS = ['2240', '8940'] as const
*
* Never cached: the whole point is that the response reflects the current
* authoritative value in user_preferences.
*
* Uses requireAuth() directly (not withRouteContext): a null companyId is a
* valid answer here the wrapper would short-circuit it into an error.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{
status: 401,
headers: { 'Cache-Control': 'private, no-store' },
},
)
const auth = await requireAuth()
if (auth.error) {
auth.error.headers.set('Cache-Control', 'private, no-store')
return auth.error
}
const { user, supabase } = auth
const companyId = await getActiveCompanyId(supabase, user.id)
@@ -72,17 +69,10 @@ const PatchBodySchema = z.object({
* entity_type='aktiebolag'. The handler rejects K3 for non-AB to prevent
* impossible chart-of-accounts states downstream.
*/
export async function PATCH(request: Request) {
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 PATCH = withRouteContext(
'company.update_current',
async (request, ctx) => {
const { supabase, companyId, user } = ctx
const validation = await validateBody(request, PatchBodySchema)
if (!validation.success) return validation.response
@@ -188,4 +178,6 @@ export async function PATCH(request: Request) {
}
return NextResponse.json({ data })
}
},
{ requireWrite: true },
)
+9 -16
View File
@@ -1,7 +1,6 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
/**
* DELETE /api/company/members/[id]
@@ -9,18 +8,10 @@ import { requireWritePermission } from '@/lib/auth/require-write'
* Only company owners and admins can remove members.
* Cannot remove team-sourced members (they must be removed from the team).
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
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 }> }>(
'company_members.remove',
async (_request, ctx, { params }) => {
const { companyId, user } = ctx
const { id: memberId } = await params
const serviceClient = await createServiceClient()
@@ -87,4 +78,6 @@ export async function DELETE(
}
return NextResponse.json({ data: { removed: memberId } })
}
},
{ requireWrite: true },
)
+9 -16
View File
@@ -1,25 +1,16 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
/**
* DELETE /api/company/members/invite/[id]
* Revoke a pending company invitation.
* Only company owners and admins can revoke.
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
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 }> }>(
'company_members.revoke_invite',
async (_request, ctx, { params }) => {
const { companyId, user } = ctx
const { id: inviteId } = await params
const serviceClient = await createServiceClient()
@@ -63,4 +54,6 @@ export async function DELETE(
}
return NextResponse.json({ data: { revoked: inviteId } })
}
},
{ requireWrite: true },
)
@@ -0,0 +1,142 @@
/**
* Tests for POST /api/company/members/invite.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
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/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/invite-tokens', () => ({
generateInviteToken: () => ({ token: 'tok-plain', hash: 'tok-hash' }),
getInviteExpiry: () => new Date('2026-08-01T00:00:00Z'),
}))
const sendEmailMock = vi.fn()
const isConfiguredMock = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: isConfiguredMock, sendEmail: sendEmailMock }),
}))
vi.mock('@/lib/email/invite-templates', () => ({
generateInviteEmailSubject: () => 'subject',
generateInviteEmailHtml: () => '<p>html</p>',
generateInviteEmailText: () => 'text',
}))
import { POST } from '../route'
const routeParams = { params: Promise.resolve({}) }
function post(body: unknown) {
return POST(
createMockRequest('/api/company/members/invite', { method: 'POST', body }),
routeParams,
)
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1', email: 'owner@example.com' },
supabase: {},
error: null,
})
requireWriteMock.mockResolvedValue({ ok: true })
isConfiguredMock.mockReturnValue(true)
sendEmailMock.mockResolvedValue({ success: true, messageId: 'msg-1' })
})
describe('POST /api/company/members/invite', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await post({ email: 'x@y.se' })
expect(res.status).toBe(401)
})
it('refuses non-admin members with 403', async () => {
enqueue({ data: { role: 'member' } }) // caller membership
const { status, body } = await parseJsonResponse<{ error: string }>(
await post({ email: 'x@y.se' })
)
expect(status).toBe(403)
expect(body.error).toBe('Behörighet saknas.')
})
it('rejects an invalid email with 400', async () => {
enqueue({ data: { role: 'owner' } })
const { status } = await parseJsonResponse(await post({ email: 'not-an-email' }))
expect(status).toBe(400)
})
it('rejects an unknown role with 400', async () => {
enqueue({ data: { role: 'owner' } })
const { status } = await parseJsonResponse(
await post({ email: 'x@y.se', role: 'superuser' })
)
expect(status).toBe(400)
})
it('creates the invitation and reports email_sent', async () => {
enqueue({ data: { role: 'owner' } }) // caller membership
enqueue({ data: [] }) // existing members
enqueue({ data: null }) // existing invite
enqueue({ data: { name: 'Acme AB' } }) // company name
enqueue({ data: null }) // insert invitation
const { status, body } = await parseJsonResponse<{
data: { email: string; email_sent: boolean }
}>(await post({ email: 'Client@Example.com', role: 'viewer' }))
expect(status).toBe(200)
expect(body.data.email).toBe('client@example.com') // normalized
expect(body.data.email_sent).toBe(true)
expect(sendEmailMock).toHaveBeenCalledWith(
expect.objectContaining({ to: 'client@example.com' })
)
})
it('reports email_sent=false when the send fails (invite still created)', async () => {
enqueue({ data: { role: 'owner' } })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: { name: 'Acme AB' } })
enqueue({ data: null })
sendEmailMock.mockResolvedValue({ success: false, error: 'smtp down' })
const { status, body } = await parseJsonResponse<{
data: { email_sent: boolean; status: string }
}>(await post({ email: 'client@example.com' }))
expect(status).toBe(200)
expect(body.data.status).toBe('pending')
expect(body.data.email_sent).toBe(false)
})
})
+149 -153
View File
@@ -1,8 +1,9 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens'
import { getEmailService } from '@/lib/email/service'
import {
@@ -17,168 +18,163 @@ import {
// init'd route in the process.
ensureInitialized()
const InviteSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.string().email('Ogiltig e-postadress.')),
role: z.enum(['admin', 'member', 'viewer']).default('viewer'),
})
/**
* POST /api/company/members/invite
* Invite a user to the current company (e.g., a client as viewer).
* Only company owners and admins can invite.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const POST = withRouteContext(
'company_members.invite',
async (request, ctx) => {
const { companyId, user, log } = ctx
const serviceClient = await createServiceClient()
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
// Check caller has permission (owner/admin — stricter than requireWrite)
const { data: callerMembership } = await serviceClient
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.single()
const companyId = await requireCompanyId(supabase, user.id)
const serviceClient = await createServiceClient()
// Check caller has permission
const { data: callerMembership } = await serviceClient
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.single()
if (!callerMembership || !['owner', 'admin'].includes(callerMembership.role)) {
return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
}
const body = await request.json()
const email = (body.email as string || '').trim().toLowerCase()
const role = (body.role as string) || 'viewer'
if (!email || !email.includes('@')) {
return NextResponse.json({ error: 'Ogiltig e-postadress.' }, { status: 400 })
}
if (!['admin', 'member', 'viewer'].includes(role)) {
return NextResponse.json({ error: 'Ogiltig roll.' }, { status: 400 })
}
// Check if email is already a member of this company
const { data: existingMembers } = await serviceClient
.from('company_members')
.select('id, user_id')
.eq('company_id', companyId)
if (existingMembers && existingMembers.length > 0) {
const memberUserIds = existingMembers.map((m) => m.user_id)
const { data: memberProfiles } = await serviceClient
.from('profiles')
.select('id, email')
.in('id', memberUserIds)
const alreadyMember = memberProfiles?.some(
(p) => p.email?.toLowerCase() === email
)
if (alreadyMember) {
return NextResponse.json({ error: 'Denna person är redan medlem.' }, { status: 409 })
}
}
// Check for existing pending invite
const { data: existingInvite } = await serviceClient
.from('company_invitations')
.select('id, status')
.eq('company_id', companyId)
.eq('email', email)
.single()
if (existingInvite && existingInvite.status === 'pending') {
return NextResponse.json({ error: 'En inbjudan har redan skickats till denna e-post.' }, { status: 409 })
}
// Get company name for the email
const { data: company } = await serviceClient
.from('companies')
.select('name')
.eq('id', companyId)
.single()
// Generate token
const { token, hash } = generateInviteToken()
const expiresAt = getInviteExpiry()
// Upsert invitation
if (existingInvite) {
const { error } = await serviceClient
.from('company_invitations')
.update({
token_hash: hash,
invited_by: user.id,
status: 'pending',
expires_at: expiresAt.toISOString(),
role,
})
.eq('id', existingInvite.id)
if (error) {
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
}
} else {
const { error } = await serviceClient
.from('company_invitations')
.insert({
company_id: companyId,
email,
role,
token_hash: hash,
invited_by: user.id,
status: 'pending',
expires_at: expiresAt.toISOString(),
})
if (error) {
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
}
}
// Send email
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const emailService = getEmailService()
if (emailService.isConfigured()) {
const inviteUrl = `${appUrl}/invite/${token}`
const emailData = {
companyName: company?.name || 'Företag',
inviterEmail: user.email || '',
inviteUrl,
if (!callerMembership || !['owner', 'admin'].includes(callerMembership.role)) {
return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
}
console.log('[company/members/invite] sending email', {
to: email,
company: emailData.companyName,
from: user.email,
const validation = await validateBody(request, InviteSchema, {
log,
operation: 'company_members.invite',
})
if (!validation.success) return validation.response
const { email, role } = validation.data
const result = await emailService.sendEmail({
to: email,
subject: generateInviteEmailSubject(emailData),
html: generateInviteEmailHtml(emailData),
text: generateInviteEmailText(emailData),
})
// Check if email is already a member of this company
const { data: existingMembers } = await serviceClient
.from('company_members')
.select('id, user_id')
.eq('company_id', companyId)
if (result.success) {
console.log('[company/members/invite] email sent', {
to: email,
messageId: result.messageId,
})
if (existingMembers && existingMembers.length > 0) {
const memberUserIds = existingMembers.map((m) => m.user_id)
const { data: memberProfiles } = await serviceClient
.from('profiles')
.select('id, email')
.in('id', memberUserIds)
const alreadyMember = memberProfiles?.some(
(p) => p.email?.toLowerCase() === email
)
if (alreadyMember) {
return NextResponse.json({ error: 'Denna person är redan medlem.' }, { status: 409 })
}
}
// Check for existing pending invite
const { data: existingInvite } = await serviceClient
.from('company_invitations')
.select('id, status')
.eq('company_id', companyId)
.eq('email', email)
.single()
if (existingInvite && existingInvite.status === 'pending') {
return NextResponse.json({ error: 'En inbjudan har redan skickats till denna e-post.' }, { status: 409 })
}
// Get company name for the email
const { data: company } = await serviceClient
.from('companies')
.select('name')
.eq('id', companyId)
.single()
// Generate token
const { token, hash } = generateInviteToken()
const expiresAt = getInviteExpiry()
// Upsert invitation
if (existingInvite) {
const { error } = await serviceClient
.from('company_invitations')
.update({
token_hash: hash,
invited_by: user.id,
status: 'pending',
expires_at: expiresAt.toISOString(),
role,
})
.eq('id', existingInvite.id)
if (error) {
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
}
} else {
console.error('[company/members/invite] email send failed:', result.error)
const { error } = await serviceClient
.from('company_invitations')
.insert({
company_id: companyId,
email,
role,
token_hash: hash,
invited_by: user.id,
status: 'pending',
expires_at: expiresAt.toISOString(),
})
if (error) {
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
}
}
} else {
console.warn('[company/members/invite] email service not configured: skipping send', {
to: email,
// Send email. email_sent is surfaced in the response so the UI can tell
// the user when the invitation exists but the mail never went out:
// previously a send failure was invisible (invite looked sent).
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const emailService = getEmailService()
let emailSent = false
if (emailService.isConfigured()) {
const inviteUrl = `${appUrl}/invite/${token}`
const emailData = {
companyName: company?.name || 'Företag',
inviterEmail: user.email || '',
inviteUrl,
}
const result = await emailService.sendEmail({
to: email,
subject: generateInviteEmailSubject(emailData),
html: generateInviteEmailHtml(emailData),
text: generateInviteEmailText(emailData),
})
if (result.success) {
emailSent = true
log.info('invite email sent', { to: email, messageId: result.messageId })
} else {
log.error('invite email send failed', new Error(result.error ?? 'unknown'), { to: email })
}
} else {
log.warn('email service not configured: invite email skipped', { to: email })
}
// In development, return the invite URL directly (no email service)
const isDev = process.env.NODE_ENV === 'development'
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
return NextResponse.json({
data: {
email,
status: 'pending',
email_sent: emailSent,
...(isDev && { inviteUrl: devInviteUrl }),
},
})
}
// In development, return the invite URL directly (no email service)
const isDev = process.env.NODE_ENV === 'development'
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
return NextResponse.json({
data: { email, status: 'pending', ...(isDev && { inviteUrl: devInviteUrl }) },
})
}
},
{ requireWrite: true },
)
+7 -9
View File
@@ -1,17 +1,15 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
/**
* GET /api/company/members
* Returns members and pending invitations for the current company.
* Service client on purpose: profiles/emails of other members aren't readable
* through the caller's RLS context; every query still scopes by companyId.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
export const GET = withRouteContext('company_members.list', async (_request, ctx) => {
const { companyId, user } = ctx
const serviceClient = await createServiceClient()
// Fetch members (source column may not exist if migration not yet applied)
@@ -77,4 +75,4 @@ export async function GET() {
canInvite,
},
})
}
})
+6 -5
View File
@@ -1,5 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
/**
* GET /api/company?owned=true&archived=false
@@ -9,12 +9,13 @@ import { NextResponse } from 'next/server'
* - archived=false only non-archived companies (default)
*
* Used by the account danger zone to show a blockers list before
* allowing account deletion.
* allowing account deletion. User-level (spans ALL memberships), so it uses
* requireAuth() directly no single active-company context applies.
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const auth = await requireAuth()
if (auth.error) return auth.error
const { user, supabase } = auth
const url = new URL(request.url)
const ownedOnly = url.searchParams.get('owned') === 'true'
+13 -16
View File
@@ -1,26 +1,17 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { getActiveCompanyId } from '@/lib/company/context'
import { guardSandbox } from '@/lib/sandbox/guard'
import type { Currency } from '@/types'
const VALID_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Riksbanken's open API is IP rate-limited — the sandbox guard keeps demo
// traffic from eating that budget (withRouteContext already refuses
// sessions without an active company).
export const GET = withRouteContext('currency.rate', async (request, ctx) => {
const { supabase, companyId } = ctx
const companyId = await getActiveCompanyId(supabase, user.id)
// Refuse the request when no active company resolves rather than letting
// a session without one slip past the sandbox guard. Riksbanken's open
// API is IP rate-limited; we don't want demo traffic eating that budget.
if (!companyId) {
return NextResponse.json({ error: 'No active company' }, { status: 400 })
}
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
@@ -32,6 +23,12 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Invalid currency' }, { status: 400 })
}
// Reject malformed dates up front — an Invalid Date would otherwise reach
// the Riksbanken request as "NaN-NaN-NaN".
if (dateStr && !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
return NextResponse.json({ error: 'Invalid date (expected YYYY-MM-DD)' }, { status: 400 })
}
const date = dateStr ? new Date(dateStr) : undefined
const rate = await fetchExchangeRate(currency, date)
@@ -40,4 +37,4 @@ export async function GET(request: Request) {
}
return NextResponse.json({ data: rate })
}
})
@@ -0,0 +1,123 @@
/**
* Tests for /api/deadlines/[id] validated PUT and count-checked DELETE.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
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),
}))
import { PUT, DELETE } from '../route'
const idParams = { params: Promise.resolve({ id: 'deadline-1' }) }
function createCapturingSupabase(
results: { data?: unknown; error?: unknown; count?: number | null }[]
) {
let idx = 0
const makeBuilder = () => {
const result = results[idx++] ?? { data: null, error: null, count: null }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: any = {}
for (const m of ['select', 'eq', 'update', 'delete', 'single', 'maybeSingle']) {
b[m] = () => b
}
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null, count: result.count ?? null })
return b
}
return { from: () => makeBuilder() }
}
beforeEach(() => {
vi.clearAllMocks()
requireWriteMock.mockResolvedValue({ ok: true })
})
function auth(supabase: unknown) {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
describe('PUT /api/deadlines/[id]', () => {
it('rejects a malformed body (bad due_date) with 400', async () => {
auth(createCapturingSupabase([]))
const req = createMockRequest('/api/deadlines/deadline-1', {
method: 'PUT',
body: { due_date: 'banana' },
})
const { status } = await parseJsonResponse(await PUT(req, idParams))
expect(status).toBe(400)
})
it('rejects an empty body with 400', async () => {
auth(createCapturingSupabase([]))
const req = createMockRequest('/api/deadlines/deadline-1', { method: 'PUT', body: {} })
const { status } = await parseJsonResponse(await PUT(req, idParams))
expect(status).toBe(400)
})
it('maps zero-rows to 404', async () => {
auth(createCapturingSupabase([{ error: { code: 'PGRST116', message: 'no rows' } }]))
const req = createMockRequest('/api/deadlines/deadline-1', {
method: 'PUT',
body: { title: 'Momsdeklaration Q3' },
})
const { status } = await parseJsonResponse(await PUT(req, idParams))
expect(status).toBe(404)
})
it('updates the deadline', async () => {
auth(createCapturingSupabase([{ data: { id: 'deadline-1', title: 'Momsdeklaration Q3' } }]))
const req = createMockRequest('/api/deadlines/deadline-1', {
method: 'PUT',
body: { title: 'Momsdeklaration Q3' },
})
const { status, body } = await parseJsonResponse<{ data: { title: string } }>(
await PUT(req, idParams)
)
expect(status).toBe(200)
expect(body.data.title).toBe('Momsdeklaration Q3')
})
})
describe('DELETE /api/deadlines/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
expect(res.status).toBe(401)
})
it('returns 404 instead of phantom success when no row matches', async () => {
auth(createCapturingSupabase([{ count: 0 }]))
const { status } = await parseJsonResponse(
await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
)
expect(status).toBe(404)
})
it('deletes the deadline', async () => {
auth(createCapturingSupabase([{ count: 1 }]))
const { status, body } = await parseJsonResponse<{ success: boolean }>(
await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
)
expect(status).toBe(200)
expect(body.success).toBe(true)
})
})
+37 -51
View File
@@ -1,63 +1,49 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
/**
* POST /api/deadlines/[id]/complete
* Toggle completion status of a deadline
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'deadline.toggle_complete',
async (_request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId } = ctx
const {
data: { user },
} = await supabase.auth.getUser()
// First, get current deadline state
const { data: existing, error: fetchError } = await supabase
.from('deadlines')
.select('is_completed')
.eq('id', id)
.eq('company_id', companyId)
.single()
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)
// First, get current deadline state
const { data: existing, error: fetchError } = await supabase
.from('deadlines')
.select('is_completed')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Toggle completion
const newCompletedState = !existing.is_completed
const { data, error } = await supabase
.from('deadlines')
.update({
is_completed: newCompletedState,
completed_at: newCompletedState ? new Date().toISOString() : null,
})
.eq('id', id)
.eq('company_id', companyId)
.select('*, customer:customers(id, name)')
.single()
// Toggle completion
const newCompletedState = !existing.is_completed
const { data, error } = await supabase
.from('deadlines')
.update({
is_completed: newCompletedState,
completed_at: newCompletedState ? new Date().toISOString() : null,
})
.eq('id', id)
.eq('company_id', companyId)
.select('*, customer:customers(id, name)')
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
+88 -117
View File
@@ -1,147 +1,118 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { CreateDeadlineInput } from '@/types'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
// Sparse update: every Create field, optional. Validated — the previous
// implementation type-asserted the raw JSON, so malformed values reached
// Postgres and malformed JSON crashed the handler.
const UpdateDeadlineSchema = CreateDeadlineSchema.partial()
/**
* GET /api/deadlines/[id]
* Get a single deadline by ID
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'deadline.get',
async (_request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId } = ctx
const {
data: { user },
} = await supabase.auth.getUser()
const { data, error } = await supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
return NextResponse.json({ data })
},
)
/**
* PUT /api/deadlines/[id]
* Update a deadline
*/
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
'deadline.update',
async (request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId, log } = ctx
const {
data: { user },
} = await supabase.auth.getUser()
const validation = await validateBody(request, UpdateDeadlineSchema, {
log,
operation: 'deadline.update',
})
if (!validation.success) return validation.response
const body = validation.data
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.title !== undefined) updateData.title = body.title
if (body.due_date !== undefined) updateData.due_date = body.due_date
if (body.due_time !== undefined) updateData.due_time = body.due_time
if (body.deadline_type !== undefined) updateData.deadline_type = body.deadline_type
if (body.priority !== undefined) updateData.priority = body.priority
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
if (body.notes !== undefined) updateData.notes = body.notes
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body: Partial<CreateDeadlineInput> = await request.json()
// First, get existing deadline to verify ownership
const { data: _existing, error: fetchError } = await supabase
.from('deadlines')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
if (Object.keys(updateData).length === 0) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.title !== undefined) updateData.title = body.title
if (body.due_date !== undefined) updateData.due_date = body.due_date
if (body.due_time !== undefined) updateData.due_time = body.due_time
if (body.deadline_type !== undefined) updateData.deadline_type = body.deadline_type
if (body.priority !== undefined) updateData.priority = body.priority
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
if (body.notes !== undefined) updateData.notes = body.notes
const { data, error } = await supabase
.from('deadlines')
.update(updateData)
.eq('id', id)
.eq('company_id', companyId)
.select('*, customer:customers(id, name)')
.single()
// Update the deadline
const { data, error } = await supabase
.from('deadlines')
.update(updateData)
.eq('id', id)
.eq('company_id', companyId)
.select('*, customer:customers(id, name)')
.single()
if (error) {
// PGRST116 = zero rows — the deadline doesn't exist in this company.
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
/**
* DELETE /api/deadlines/[id]
* Delete a deadline
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'deadline.delete',
async (_request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId } = ctx
const {
data: { user },
} = await supabase.auth.getUser()
const { error, count } = await supabase
.from('deadlines')
.delete({ count: 'exact' })
.eq('id', id)
.eq('company_id', companyId)
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Zero rows = wrong id / another company's deadline — not a success.
if (count === 0) {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
.from('deadlines')
.delete()
.eq('id', id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
+60 -93
View File
@@ -1,114 +1,81 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { DeadlineStatus } from '@/types'
const ALL_STATUSES = [
'upcoming',
'action_needed',
'in_progress',
'submitted',
'confirmed',
'overdue',
] as const satisfies readonly DeadlineStatus[]
const PatchStatusSchema = z.object({
status: z.enum(ALL_STATUSES),
})
/**
* PATCH /api/deadlines/[id]/status
* Manually update a deadline's status
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'deadline.set_status',
async (request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId, log } = ctx
const { data: { user } } = await supabase.auth.getUser()
const validation = await validateBody(request, PatchStatusSchema, {
log,
operation: 'deadline.set_status',
})
if (!validation.success) return validation.response
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await updateDeadlineStatus(supabase, id, companyId, validation.data.status)
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const body = await request.json()
const newStatus = body.status as DeadlineStatus
if (!newStatus) {
return NextResponse.json({ error: 'Status is required' }, { status: 400 })
}
const validStatuses: DeadlineStatus[] = [
'upcoming',
'action_needed',
'in_progress',
'submitted',
'confirmed',
'overdue',
]
if (!validStatuses.includes(newStatus)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const result = await updateDeadlineStatus(supabase, id, companyId, newStatus)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ success: true })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
/**
* GET /api/deadlines/[id]/status
* Get current status and valid transitions
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'deadline.get_status',
async (_request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId } = ctx
const { data: { user } } = await supabase.auth.getUser()
const { data: deadline, error } = await supabase
.from('deadlines')
.select('status, is_completed, due_date')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const { data: deadline, error } = await supabase
.from('deadlines')
.select('status, is_completed, due_date')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error || !deadline) {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
// Calculate valid transitions from current status
const validTransitions: DeadlineStatus[] = []
const allStatuses: DeadlineStatus[] = [
'upcoming',
'action_needed',
'in_progress',
'submitted',
'confirmed',
'overdue',
]
for (const status of allStatuses) {
if (isValidTransition(deadline.status, status)) {
validTransitions.push(status)
if (error || !deadline) {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
}
return NextResponse.json({
currentStatus: deadline.status,
isCompleted: deadline.is_completed,
dueDate: deadline.due_date,
validTransitions,
})
}
// Calculate valid transitions from current status
const validTransitions = ALL_STATUSES.filter((status) =>
isValidTransition(deadline.status, status)
)
return NextResponse.json({
currentStatus: deadline.status,
isCompleted: deadline.is_completed,
dueDate: deadline.due_date,
validTransitions,
})
},
)
+11 -32
View File
@@ -1,9 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* GET /api/deadlines
@@ -14,18 +12,8 @@ import { requireWritePermission } from '@/lib/auth/require-write'
* - from: ISO date string (optional)
* - to: ISO date string (optional)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
export const GET = withRouteContext('deadline.list', async (request, ctx) => {
const { supabase, companyId } = ctx
// Parse query params
const { searchParams } = new URL(request.url)
@@ -66,27 +54,16 @@ export async function GET(request: Request) {
}
return NextResponse.json({ data })
}
})
/**
* POST /api/deadlines
* Create a new deadline
*/
export async function POST(request: Request) {
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 POST = withRouteContext(
'deadline.create',
async (request, ctx) => {
const { supabase, companyId, user } = ctx
const validation = await validateBody(request, CreateDeadlineSchema)
if (!validation.success) return validation.response
@@ -114,4 +91,6 @@ export async function POST(request: Request) {
}
return NextResponse.json({ data })
}
},
{ requireWrite: true },
)
+19 -9
View File
@@ -15,6 +15,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { validateBody } from '@/lib/api/validate'
import { CreateDimensionSchema } from '@/lib/api/schemas'
import { errorResponse } from '@/lib/errors/get-structured-error'
@@ -71,19 +72,28 @@ export const GET = withRouteContext(
return errorResponse(dimsError, log, { requestId })
}
const { data: values, error: valuesError } = await supabase
.from('dimension_values')
.select('id, dimension_id, code, name, is_active, start_date, end_date')
.eq('company_id', companyId)
.order('code', { ascending: true })
if (valuesError) {
log.error('dimension value list failed', valuesError)
// Paginated: import-existing can mint one value row per historical code
// (thousands for project-heavy SIE histories), which exceeds PostgREST's
// 1000-row cap and would silently drop codes from the register/pickers.
// Secondary order on id gives the stable total order .range() requires.
let values: DimensionValueRow[]
try {
values = await fetchAllRows<DimensionValueRow>(({ from, to }) =>
supabase
.from('dimension_values')
.select('id, dimension_id, code, name, is_active, start_date, end_date')
.eq('company_id', companyId)
.order('code', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
)
} catch (valuesError) {
log.error('dimension value list failed', valuesError as Error)
return errorResponse(valuesError, log, { requestId })
}
const valuesByDimension = new Map<string, Omit<DimensionValueRow, 'dimension_id'>[]>()
for (const v of (values ?? []) as DimensionValueRow[]) {
for (const v of values) {
const bucket = valuesByDimension.get(v.dimension_id) ?? []
bucket.push({
id: v.id,
+4 -13
View File
@@ -1,22 +1,13 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { withRouteContext } from '@/lib/api/with-route-context'
/**
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
* Returns attachment counts per journal entry ID.
* Max 50 IDs per request.
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
export const GET = withRouteContext('document.counts', async (request, ctx) => {
const { supabase, companyId } = ctx
const { searchParams } = new URL(request.url)
const idsParam = searchParams.get('journal_entry_ids')
@@ -55,4 +46,4 @@ export async function GET(request: Request) {
}
return NextResponse.json({ data: counts })
}
})

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