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:
@@ -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
@@ -15,6 +15,7 @@ tests/
|
||||
.vscode/
|
||||
*.md
|
||||
!README.md
|
||||
!DOCKER.md
|
||||
docs/*.md
|
||||
!docs/DOCKER.md
|
||||
LICENSE
|
||||
docker-compose*.yml
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -23,20 +23,24 @@ export default async function ChatConversationPage({ params }: PageProps) {
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) redirect('/onboarding')
|
||||
|
||||
const { data: conversation } = await supabase
|
||||
// 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()
|
||||
|
||||
if (!conversation) notFound()
|
||||
|
||||
const { data: messages } = await supabase
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('agent_messages')
|
||||
.select('role, content, hidden, created_at')
|
||||
.eq('conversation_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
.order('created_at', { ascending: true }),
|
||||
])
|
||||
|
||||
if (!conversation) notFound()
|
||||
|
||||
return (
|
||||
<ChatConversationView
|
||||
|
||||
@@ -127,7 +127,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
async function fetchInvoice() {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data, error } = await supabase
|
||||
// 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(`
|
||||
*,
|
||||
@@ -135,7 +139,24 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single()
|
||||
.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,44 +201,58 @@ 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
|
||||
// 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()
|
||||
|
||||
if (creditNoteData) {
|
||||
setCreditNote(creditNoteData as Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a credit note, fetch the original invoice
|
||||
if (data.credited_invoice_id) {
|
||||
const { data: originalData } = await supabase
|
||||
: Promise.resolve(null),
|
||||
data.credited_invoice_id
|
||||
? supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('id', data.credited_invoice_id)
|
||||
.single()
|
||||
|
||||
if (originalData) {
|
||||
setOriginalInvoice(originalData as Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
// If this invoice was converted from a proforma, fetch it
|
||||
if (data.converted_from_id) {
|
||||
const { data: convertedData } = await supabase
|
||||
: 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 (convertedData) {
|
||||
setConvertedFromInvoice(convertedData as Invoice)
|
||||
if (settingsRes) {
|
||||
const settings = settingsRes.data
|
||||
setOreRounding(settings?.ore_rounding ?? true)
|
||||
if (typeof settings?.vat_registered === 'boolean') {
|
||||
setVatRegistered(settings.vat_registered)
|
||||
}
|
||||
}
|
||||
if (creditNoteRes?.data) {
|
||||
setCreditNote(creditNoteRes.data as Invoice)
|
||||
}
|
||||
if (originalRes?.data) {
|
||||
setOriginalInvoice(originalRes.data as Invoice)
|
||||
}
|
||||
if (convertedRes?.data) {
|
||||
setConvertedFromInvoice(convertedRes.data as Invoice)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
+49
-54
@@ -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.
|
||||
const headerStore = await headers()
|
||||
headers(),
|
||||
supabase
|
||||
.from('team_members')
|
||||
.select('team_id, role, teams:team_id(*)')
|
||||
.eq('user_id', user.id)
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
<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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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 på 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 på 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>
|
||||
)}
|
||||
|
||||
+310
-64
@@ -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 })
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [runsRes, empRes] = await Promise.all([
|
||||
const load = useCallback(async () => {
|
||||
const [runsRes, empRes, settingsRes] = await Promise.all([
|
||||
fetch('/api/salary/runs'),
|
||||
fetch('/api/salary/employees'),
|
||||
fetch('/api/settings'),
|
||||
])
|
||||
|
||||
let loadedRuns: SalaryRun[] = []
|
||||
if (runsRes.ok) {
|
||||
const { data } = await runsRes.json()
|
||||
setRuns(data || [])
|
||||
loadedRuns = data || []
|
||||
setRuns(loadedRuns)
|
||||
}
|
||||
if (empRes.ok) {
|
||||
const { data } = await empRes.json()
|
||||
setEmployeeCount((data || []).length)
|
||||
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)
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
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}>
|
||||
<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('new_run')}
|
||||
)}
|
||||
{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 på 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
@@ -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')
|
||||
}
|
||||
@@ -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[])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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(),
|
||||
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(),
|
||||
])
|
||||
.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
|
||||
|
||||
@@ -18,8 +18,20 @@ export default async function SelectCompanyPage() {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// 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.
|
||||
const { data: memberships } = await supabase
|
||||
{ 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,
|
||||
@@ -32,7 +44,20 @@ export default async function SelectCompanyPage() {
|
||||
)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.order('joined_at', { ascending: true })
|
||||
.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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,77 +19,87 @@ 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 },
|
||||
)
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'agent.conversations.get',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, user } = ctx
|
||||
|
||||
// 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) return NextResponse.json({ error: convErr.message }, { status: 500 })
|
||||
if (!conv) return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
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 })
|
||||
}
|
||||
// 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 NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
}
|
||||
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 })
|
||||
if (msgErr) throw msgErr
|
||||
|
||||
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
|
||||
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 { supabase, user, log } = ctx
|
||||
|
||||
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 })
|
||||
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
|
||||
@@ -97,10 +108,9 @@ export async function PATCH(
|
||||
.from('agent_conversations')
|
||||
.select('user_id, company_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!existing || existing.user_id !== user.id) {
|
||||
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
}
|
||||
if (!existing) return notFound()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_conversations')
|
||||
@@ -110,6 +120,7 @@ export async function PATCH(
|
||||
.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 })
|
||||
if (error) throw error
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 ?? [] })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,28 +30,30 @@ 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 writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
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 notFound = () =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'MEMORY_NOT_FOUND',
|
||||
message: 'Minnet hittades inte.',
|
||||
message_en: 'Memory not found.',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
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 validation = await validateBody(request, PatchSchema, {
|
||||
log,
|
||||
operation: 'agent.memory.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const update: Record<string, unknown> = {}
|
||||
if (body.content !== undefined) update.content = body.content
|
||||
@@ -64,7 +66,7 @@ export async function PATCH(
|
||||
.select('company_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
if (!existing) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
|
||||
if (!existing) return notFound()
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
@@ -72,7 +74,7 @@ export async function PATCH(
|
||||
.eq('company_id', existing.company_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
|
||||
if (!membership) return notFound()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_memory')
|
||||
@@ -83,8 +85,10 @@ export async function PATCH(
|
||||
'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 })
|
||||
if (error) throw error
|
||||
if (!data) return notFound()
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,34 +73,27 @@ 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
|
||||
const validation = await validateBody(request, BodySchema, {
|
||||
log,
|
||||
operation: 'agent.memory.create',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
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 ?? activeCompanyId
|
||||
|
||||
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.
|
||||
// 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')
|
||||
@@ -109,7 +102,13 @@ export async function POST(request: Request) {
|
||||
.maybeSingle()
|
||||
if (!bodyMembership || bodyMembership.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 },
|
||||
)
|
||||
}
|
||||
@@ -126,11 +125,11 @@ export async function POST(request: Request) {
|
||||
is_active: true,
|
||||
created_by_user_id: user.id,
|
||||
})
|
||||
.select(
|
||||
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
|
||||
)
|
||||
.select(MEMORY_COLUMNS)
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) throw error
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
// 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)
|
||||
|
||||
const { data, error } = await query.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
log.error('article list failed', error)
|
||||
return errorResponse(error, log, { requestId })
|
||||
}
|
||||
return query
|
||||
.order('name', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
})
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get posted journal entries within date range
|
||||
let entriesQuery = supabase
|
||||
// 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')
|
||||
|
||||
if (dateFrom) {
|
||||
entriesQuery = entriesQuery.gte('entry_date', dateFrom)
|
||||
}
|
||||
if (dateTo) {
|
||||
entriesQuery = entriesQuery.lte('entry_date', dateTo)
|
||||
}
|
||||
if (dateFrom) query = query.gte('entry_date', dateFrom)
|
||||
if (dateTo) query = query.lte('entry_date', dateTo)
|
||||
|
||||
const { data: entries, error: entriesError } = await entriesQuery
|
||||
return query.order('id', { ascending: true }).range(f, t)
|
||||
})
|
||||
|
||||
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,20 +62,23 @@ 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
|
||||
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)
|
||||
|
||||
if (linesError) {
|
||||
return NextResponse.json({ error: linesError.message }, { status: 500 })
|
||||
}
|
||||
if (lines) {
|
||||
.order('id', { ascending: true })
|
||||
.range(f, t)
|
||||
)
|
||||
allLines.push(...lines)
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate by account
|
||||
const accountTotals = new Map<string, { debit: number; credit: number }>()
|
||||
@@ -132,4 +133,4 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ totals })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
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 }> }
|
||||
) {
|
||||
// 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.
|
||||
|
||||
export const DELETE = withRouteContext(
|
||||
'bookkeeping.accounts.delete',
|
||||
async (_request, ctx, { params }: { params: Promise<{ number: string }> }) => {
|
||||
const { number } = 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
|
||||
|
||||
// Fetch the account to check if it's a system account
|
||||
const { data: account, error: fetchError } = await supabase
|
||||
@@ -41,10 +33,14 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Check if account is referenced in posted journal entries
|
||||
// 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', { count: 'exact', head: true })
|
||||
.select('id, journal_entries!inner(company_id)', { count: 'exact', head: true })
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('account_number', number)
|
||||
|
||||
if (count && count > 0) {
|
||||
@@ -65,29 +61,27 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
export const PUT = withRouteContext(
|
||||
'bookkeeping.accounts.update',
|
||||
async (request, ctx, { params }: { params: Promise<{ number: string }> }) => {
|
||||
const { number } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
const { supabase, companyId, 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 validation = await validateBody(request, UpdateAccountSchema)
|
||||
const validation = await validateBody(request, UpdateAccountSchema, {
|
||||
log,
|
||||
operation: 'bookkeeping.accounts.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update(body)
|
||||
@@ -97,8 +91,14 @@ export async function PUT(
|
||||
.single()
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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,28 +11,27 @@ 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) {
|
||||
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 uniqueNumbers = [...new Set(accountNumbers)]
|
||||
const uniqueNumbers = [...new Set(parsed.data.account_numbers)]
|
||||
|
||||
// Fetch existing rows with current is_active state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
@@ -105,7 +103,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,27 +45,25 @@ 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)
|
||||
const validation = await validateBody(request, CreateAccountSchema, {
|
||||
log,
|
||||
operation: 'bookkeeping.accounts.create',
|
||||
})
|
||||
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({
|
||||
@@ -94,4 +96,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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,25 +1,15 @@
|
||||
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 }> }
|
||||
) {
|
||||
// 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 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 { user, supabase, companyId } = ctx
|
||||
|
||||
try {
|
||||
const period = await closePeriod(supabase, companyId, user.id, id)
|
||||
@@ -30,4 +20,6 @@ export async function POST(
|
||||
{ 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,20 +1,11 @@
|
||||
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(
|
||||
'period.entry_count',
|
||||
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 companyId = await requireCompanyId(supabase, user.id)
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -39,4 +30,5 @@ export async function GET(
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }> }
|
||||
) {
|
||||
// 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
|
||||
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, UpdateNotesSchema)
|
||||
if (!result.success) return result.response
|
||||
|
||||
const { error } = await supabase
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ notes: result.data.notes })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('id')
|
||||
.maybeSingle()
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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,19 +14,10 @@ const logger = createLogger('journal-entries')
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.get',
|
||||
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)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
@@ -43,24 +31,13 @@ export async function GET(
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.delete',
|
||||
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)
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,22 +18,17 @@ 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)
|
||||
const result = await validateBody(request, CreateMappingRuleSchema, {
|
||||
log,
|
||||
operation: 'mapping_rules.create',
|
||||
})
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
@@ -53,19 +39,21 @@ export async function POST(request: Request) {
|
||||
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,
|
||||
// ?? 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,
|
||||
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()
|
||||
@@ -75,4 +63,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,8 +55,14 @@ 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 }>) {
|
||||
// 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,
|
||||
@@ -63,7 +71,6 @@ export async function GET(request: Request) {
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch existing explanations and match them
|
||||
if (allGaps.length > 0) {
|
||||
@@ -102,22 +109,17 @@ 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 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)
|
||||
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
|
||||
|
||||
@@ -150,4 +152,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,41 +44,23 @@ 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()
|
||||
|
||||
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(
|
||||
'calendar_feed.create',
|
||||
async (_request, ctx) => {
|
||||
const { supabase, companyId, user } = ctx
|
||||
|
||||
// Check if feed already exists
|
||||
const { data: existingFeed } = await supabase
|
||||
@@ -97,40 +93,31 @@ export async function POST() {
|
||||
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}`,
|
||||
},
|
||||
data: { ...feed, ...feedUrls(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()
|
||||
|
||||
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 body: UpdateCalendarFeedInput = await request.json()
|
||||
const validation = await validateBody(request, UpdateFeedSchema, {
|
||||
log,
|
||||
operation: 'calendar_feed.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.update(body)
|
||||
.update(validation.data)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
@@ -139,34 +126,21 @@ export async function PUT(request: Request) {
|
||||
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}`,
|
||||
},
|
||||
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()
|
||||
|
||||
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(
|
||||
'calendar_feed.rotate_token',
|
||||
async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
// Generate a new token by updating with a new UUID
|
||||
const { data: feed, error } = await supabase
|
||||
@@ -184,13 +158,9 @@ export async function DELETE() {
|
||||
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}`,
|
||||
},
|
||||
data: { ...feed, ...feedUrls(feed.feed_token) },
|
||||
})
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,23 +18,23 @@ 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 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const POST = withRouteContext(
|
||||
'company_members.invite',
|
||||
async (request, ctx) => {
|
||||
const { companyId, user, log } = ctx
|
||||
const serviceClient = await createServiceClient()
|
||||
|
||||
// Check caller has permission
|
||||
// Check caller has permission (owner/admin — stricter than requireWrite)
|
||||
const { data: callerMembership } = await serviceClient
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
@@ -45,17 +46,12 @@ export async function POST(request: Request) {
|
||||
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 })
|
||||
}
|
||||
const validation = await validateBody(request, InviteSchema, {
|
||||
log,
|
||||
operation: 'company_members.invite',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { email, role } = validation.data
|
||||
|
||||
// Check if email is already a member of this company
|
||||
const { data: existingMembers } = await serviceClient
|
||||
@@ -135,9 +131,12 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Send 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}`
|
||||
|
||||
@@ -147,12 +146,6 @@ export async function POST(request: Request) {
|
||||
inviteUrl,
|
||||
}
|
||||
|
||||
console.log('[company/members/invite] sending email', {
|
||||
to: email,
|
||||
company: emailData.companyName,
|
||||
from: user.email,
|
||||
})
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: email,
|
||||
subject: generateInviteEmailSubject(emailData),
|
||||
@@ -161,17 +154,13 @@ export async function POST(request: Request) {
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
console.log('[company/members/invite] email sent', {
|
||||
to: email,
|
||||
messageId: result.messageId,
|
||||
})
|
||||
emailSent = true
|
||||
log.info('invite email sent', { to: email, messageId: result.messageId })
|
||||
} else {
|
||||
console.error('[company/members/invite] email send failed:', result.error)
|
||||
log.error('invite email send failed', new Error(result.error ?? 'unknown'), { to: email })
|
||||
}
|
||||
} else {
|
||||
console.warn('[company/members/invite] email service not configured: skipping send', {
|
||||
to: email,
|
||||
})
|
||||
log.warn('email service not configured: invite email skipped', { to: email })
|
||||
}
|
||||
|
||||
// In development, return the invite URL directly (no email service)
|
||||
@@ -179,6 +168,13 @@ export async function POST(request: Request) {
|
||||
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
|
||||
|
||||
return NextResponse.json({
|
||||
data: { email, status: 'pending', ...(isDev && { inviteUrl: devInviteUrl }) },
|
||||
data: {
|
||||
email,
|
||||
status: 'pending',
|
||||
email_sent: emailSent,
|
||||
...(isDev && { inviteUrl: devInviteUrl }),
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1,31 +1,15 @@
|
||||
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()
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.toggle_complete',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
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
|
||||
|
||||
// First, get current deadline state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
@@ -60,4 +44,6 @@ export async function POST(
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
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()
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.get',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
@@ -40,48 +33,25 @@ export async function GET(
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
|
||||
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 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 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
const validation = await validateBody(request, UpdateDeadlineSchema, {
|
||||
log,
|
||||
operation: 'deadline.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
@@ -93,7 +63,10 @@ export async function PUT(
|
||||
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Update the deadline
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update(updateData)
|
||||
@@ -103,45 +76,43 @@ export async function PUT(
|
||||
.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 })
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
|
||||
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 { error } = await supabase
|
||||
const { error, count } = await supabase
|
||||
.from('deadlines')
|
||||
.delete()
|
||||
.delete({ count: 'exact' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,81 +1,59 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
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 { 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[] = [
|
||||
const ALL_STATUSES = [
|
||||
'upcoming',
|
||||
'action_needed',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'confirmed',
|
||||
'overdue',
|
||||
]
|
||||
] as const satisfies readonly DeadlineStatus[]
|
||||
|
||||
if (!validStatuses.includes(newStatus)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
const PatchStatusSchema = z.object({
|
||||
status: z.enum(ALL_STATUSES),
|
||||
})
|
||||
|
||||
const result = await updateDeadlineStatus(supabase, id, companyId, newStatus)
|
||||
/**
|
||||
* PATCH /api/deadlines/[id]/status
|
||||
* Manually update a deadline's status
|
||||
*/
|
||||
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 validation = await validateBody(request, PatchStatusSchema, {
|
||||
log,
|
||||
operation: 'deadline.set_status',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const result = await updateDeadlineStatus(supabase, id, companyId, validation.data.status)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.get_status',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { data: deadline, error } = await supabase
|
||||
.from('deadlines')
|
||||
@@ -89,21 +67,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
const validTransitions = ALL_STATUSES.filter((status) =>
|
||||
isValidTransition(deadline.status, status)
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
currentStatus: deadline.status,
|
||||
@@ -111,4 +77,5 @@ export async function GET(
|
||||
dueDate: deadline.due_date,
|
||||
validTransitions,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+11
-32
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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
|
||||
// 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 })
|
||||
|
||||
if (valuesError) {
|
||||
log.error('dimension value list failed', valuesError)
|
||||
.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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user