diff --git a/DECISIONS.md b/DECISIONS.md index d1c284a6..3f5f189e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1247,6 +1247,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent. [2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed. [2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/, and /.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised. +[2026-08-26] MCP serverInfo.version, extension version and /api/health version reuse currentAppVersion() (12-char SHA, '1.0.0' fallback) instead of a new 7-char slice: one identifier across behandlingshistorik, health and MCP so a support thread can match a deploy by a single string; gnubok_get_vacation_balance got a real estimated_liability_sek by exporting semesterberedning's dayValueSek rather than dropping the description's promise, with descriptions trimmed to stay under the tools/list ceiling. [2026-08-26] raw-route-auth guard judges each top-level export segment of a route file, not the whole file: transactions/[id] (wrapped PATCH + hand-rolled DELETE) and transactions (wrapped POST + hand-rolled GET) passed the file-level check for months because one withRouteContext call exempted every sibling handler. Baseline unchanged (mcp-oauth/authorize is the one grandfathered file). [2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR. [2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay. diff --git a/app/api/health/__tests__/route.test.ts b/app/api/health/__tests__/route.test.ts new file mode 100644 index 00000000..e95022a0 --- /dev/null +++ b/app/api/health/__tests__/route.test.ts @@ -0,0 +1,70 @@ +/** + * /api/health: the version field is the build identifier (commit SHA prefix) + * when one is inlined at build, and the '1.0.0' fallback otherwise, so + * self-hosted Docker healthchecks keep a stable value. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +let selectResult: { error: { code?: string; message?: string } | null } = { error: null } + +vi.mock('@/lib/supabase/service-client', () => ({ + createServiceRoleClient: () => ({ + from: () => ({ + select: () => ({ + limit: async () => selectResult, + }), + }), + }), +})) + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) + +async function loadRoute() { + vi.resetModules() + return import('../route') +} + +beforeEach(() => { + selectResult = { error: null } + vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://example.supabase.co') + vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'service-role-key') + vi.stubEnv('VERCEL_GIT_COMMIT_SHA', '') + vi.stubEnv('NEXT_PUBLIC_BUILD_ID', '') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('GET /api/health', () => { + it('reports healthy with the 1.0.0 fallback when no build id is inlined', async () => { + const { GET } = await loadRoute() + const res = await GET() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.status).toBe('healthy') + expect(body.version).toBe('1.0.0') + expect(typeof body.timestamp).toBe('string') + }) + + it('reports the commit SHA prefix as version when VERCEL_GIT_COMMIT_SHA is set', async () => { + vi.stubEnv('VERCEL_GIT_COMMIT_SHA', 'abcdef1234567890abcdef1234567890abcdef12') + const { GET } = await loadRoute() + const body = await (await GET()).json() + expect(body.status).toBe('healthy') + expect(body.version).toBe('abcdef123456') + }) + + it('keeps the same version on the unhealthy path', async () => { + vi.stubEnv('VERCEL_GIT_COMMIT_SHA', 'abcdef1234567890abcdef1234567890abcdef12') + selectResult = { error: { code: '57P01', message: 'terminating connection' } } + const { GET } = await loadRoute() + const res = await GET() + expect(res.status).toBe(503) + const body = await res.json() + expect(body.status).toBe('unhealthy') + expect(body.version).toBe('abcdef123456') + }) +}) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index e32d7cc8..d60b97a6 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,9 +1,16 @@ import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { createLogger } from '@/lib/logger' +import { currentAppVersion } from '@/lib/reports/app-version' const log = createLogger('health') +// Build identifier (commit SHA prefix on Vercel) so operators can tell which +// deploy answered. Falls back to '1.0.0' for self-hosted builds with no SHA +// inlined, so Docker healthchecks keep a stable value. Same identifier the +// MCP serverInfo and the behandlingshistorik report stamp. +const APP_VERSION = currentAppVersion() ?? '1.0.0' + const CACHE_TTL_MS = 5_000 type HealthBody = { @@ -73,7 +80,7 @@ async function runHealthCheck(): Promise { if (!supabaseUrl || !supabaseServiceKey) { log.error('Missing Supabase configuration for health check') return { - body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' }, + body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: APP_VERSION }, status: 503, } } @@ -94,13 +101,13 @@ async function runHealthCheck(): Promise { errMessage: error.message ?? null, }) return { - body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' }, + body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: APP_VERSION }, status: 503, } } return { - body: { status: 'healthy', timestamp: new Date().toISOString(), version: '1.0.0' }, + body: { status: 'healthy', timestamp: new Date().toISOString(), version: APP_VERSION }, status: 200, } } catch (err) { @@ -114,7 +121,7 @@ async function runHealthCheck(): Promise { errCode: typeof e?.code === 'string' ? e.code : null, }) return { - body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' }, + body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: APP_VERSION }, status: 503, } } diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md index 9fc57c70..60230c6f 100644 --- a/docs/SELF-HOSTING.md +++ b/docs/SELF-HOSTING.md @@ -140,6 +140,8 @@ Verify the deployment: ```bash curl http://localhost:3000/api/health # {"status":"healthy","timestamp":"...","version":"1.0.0"} +# `version` is the build commit SHA prefix when VERCEL_GIT_COMMIT_SHA or +# NEXT_PUBLIC_BUILD_ID was set at build time, otherwise "1.0.0". ``` > **Note:** The health check queries the database, so migrations must be applied before it returns healthy. diff --git a/extensions/general/mcp-server/__tests__/namespace-compatibility.test.ts b/extensions/general/mcp-server/__tests__/namespace-compatibility.test.ts index ef353b69..54b66fcd 100644 --- a/extensions/general/mcp-server/__tests__/namespace-compatibility.test.ts +++ b/extensions/general/mcp-server/__tests__/namespace-compatibility.test.ts @@ -83,6 +83,10 @@ describe('MCP namespace compatibility', () => { ) const serverInfo = initialized.serverInfo as Record expect(serverInfo.name).toBe('gnubok') + // Build-derived (commit SHA prefix) or the '1.0.0' self-hosted fallback: + // never empty, so clients can tell deploys apart. + expect(typeof serverInfo.version).toBe('string') + expect((serverInfo.version as string).length).toBeGreaterThan(0) expect(initialized.instructions).toContain('gnubok_search_tools') const listed = await readResult(mcpRequest('tools/list')) @@ -105,6 +109,8 @@ describe('MCP namespace compatibility', () => { ) const serverInfo = initialized.serverInfo as Record expect(serverInfo.name).toBe('accounted') + expect(typeof serverInfo.version).toBe('string') + expect((serverInfo.version as string).length).toBeGreaterThan(0) expect(initialized.instructions).toContain('accounted_search_tools') const listed = await readResult(mcpRequest('tools/list', undefined, 'accounted')) diff --git a/extensions/general/mcp-server/__tests__/payroll-read-tools.test.ts b/extensions/general/mcp-server/__tests__/payroll-read-tools.test.ts index 2222ef64..a8522fbe 100644 --- a/extensions/general/mcp-server/__tests__/payroll-read-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/payroll-read-tools.test.ts @@ -294,20 +294,31 @@ describe('gnubok_get_payslip', () => { }) describe('gnubok_get_vacation_balance', () => { - it('returns the open balance with qualified id and remaining days', async () => { + const BALANCE_ROW = { + id: 'vb-1', + employee_id: 'emp-1', + vacation_year_start: '2026-01-01', + entitled_days: 25, + accrued_days: 0, + taken_days: 10, + saved_days: { '2025': 5 }, + forced_payout_days: 0, + } + // Procentregeln, 25 days, 35 000 kr/month: day value = 35000*12*0.12/25 = 2016. + const EMPLOYEE_ROW = { + vacation_rule: 'procentregeln', + vacation_days_per_year: 25, + salary_type: 'monthly', + monthly_salary: 35000, + hourly_rate: null, + hours_per_week: null, + workdays_per_week: null, + } + + it('returns the open balance with qualified id, remaining days and the SEK estimate', async () => { const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ - data: { - id: 'vb-1', - employee_id: 'emp-1', - vacation_year_start: '2026-01-01', - entitled_days: 25, - accrued_days: 0, - taken_days: 10, - saved_days: { '2025': 5 }, - forced_payout_days: 0, - }, - }) + enqueue({ data: BALANCE_ROW }) + enqueue({ data: EMPLOYEE_ROW }) const result = (await getVacationBalance.execute( { employee_id: 'emp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' }, @@ -317,6 +328,34 @@ describe('gnubok_get_vacation_balance', () => { expect(result.remaining_days).toBe(15) expect(result.saved_days).toEqual({ '2025': 5 }) expect(result.id).toBeUndefined() + // (15 remaining + 5 saved) * 2016 kr: the same valuation the v1 route and + // the year-close use, so the description's promise is a real field. + expect(result.estimated_liability_sek).toBe(40320) + expect(getVacationBalance.outputSchema?.properties).toHaveProperty('estimated_liability_sek') + }) + + it('floors the SEK estimate at zero when the balance is overdrawn', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { ...BALANCE_ROW, entitled_days: 5, taken_days: 12, saved_days: {} } }) + enqueue({ data: EMPLOYEE_ROW }) + + const result = (await getVacationBalance.execute( + { employee_id: 'emp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' }, + )) as Record + + expect(result.remaining_days).toBe(-7) + expect(result.estimated_liability_sek).toBe(0) + }) + + it('throws when the employee row is missing for the company', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: BALANCE_ROW }) + enqueue({ data: null }) + await expect( + getVacationBalance.execute( + { employee_id: 'emp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' }, + ), + ).rejects.toThrow(/Employee emp-1 not found/) }) it('throws before the ledger has seeded', async () => { diff --git a/extensions/general/mcp-server/index.ts b/extensions/general/mcp-server/index.ts index 59432342..988b7c0b 100644 --- a/extensions/general/mcp-server/index.ts +++ b/extensions/general/mcp-server/index.ts @@ -5,6 +5,7 @@ import { handleMcpRequest, tools as mcpTools } from './server' import { isForbiddenOrigin, forbiddenOriginResponse } from './origin-guard' import { registerAgentTools } from '@/lib/agent/tools/registry' import type { AgentTool } from '@/lib/agent/tools/types' +import { currentAppVersion } from '@/lib/reports/app-version' // Make the same tool set available to the in-app chat agent. The chat loop // (lib/agent/chat/*) dispatches against the core agentToolRegistry so it can @@ -15,7 +16,9 @@ registerAgentTools(mcpTools as unknown as AgentTool[]) export const mcpServerExtension: Extension = { id: 'mcp-server', name: 'MCP Server', - version: '1.0.0', + // Build-derived so deploys are distinguishable; '1.0.0' when self-hosted + // without an inlined commit SHA. Same identifier as serverInfo.version. + version: currentAppVersion() ?? '1.0.0', settingsPanel: { label: 'MCP-server (API)', diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 53217588..b6650a59 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -33,6 +33,8 @@ import { } from './public-tools' import { createLogger } from '@/lib/logger' import { roundOre, sumOre } from '@/lib/money' +import { currentAppVersion } from '@/lib/reports/app-version' +import type { DayValueEmployee } from '@/lib/salary/semesterberedning' import { getVatDeadlineForPeriod, type VatDeadlineCalculationSettings, @@ -5972,7 +5974,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_create_invoice', title: 'Create Customer Invoice', - description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Items accept dims bags. Stages for user approval: invoice number assigned at approval.', + description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Items accept dims bags. Approval creates a draft; the invoice number is assigned on send or mark-as-sent.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', @@ -14301,7 +14303,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_get_vacation_balance', title: 'Get Vacation Balance (Semestersaldo)', - description: 'Get one employee\'s current vacation balance: entitled/taken/remaining days, sparade dagar per origin year (5-year rule), forced payouts, and an estimated semesterlöneskuld in SEK. Ledger seeds on first booking. Use before gnubok_close_vacation_year.', + description: 'Get one employee\'s open vacation balance: entitled/taken/remaining days, sparade dagar per origin year, forced payouts and estimated semesterlöneskuld in SEK. Use before gnubok_close_vacation_year.', inputSchema: { type: 'object', additionalProperties: false, @@ -14323,6 +14325,7 @@ export const tools: McpTool[] = [ remaining_days: { type: 'number' }, saved_days: { type: 'object', description: 'Origin year -> days' }, forced_payout_days: { type: 'number' }, + estimated_liability_sek: { type: 'number' }, }, required: ['employee_vacation_balance_id', 'employee_id', 'vacation_year_start', 'entitled_days', 'taken_days', 'remaining_days'], }, @@ -14344,10 +14347,31 @@ export const tools: McpTool[] = [ const { id, ...rest } = balance as { id: string } & Record const entitled = (rest.entitled_days as number) ?? 0 const taken = (rest.taken_days as number) ?? 0 + const remaining = roundOre(entitled - taken) + + // Same simplified BFNAR 2016:10 day valuation the year-close and the v1 + // vacation-balance route use, so the three surfaces agree on the SEK + // estimate. remaining + sparade dagar, floored at zero: an overdrawn + // balance is a receivable, not a negative skuld. + const { dayValueSek } = await import('@/lib/salary/semesterberedning') + const { data: employee, error: empErr } = await supabase + .from('employees') + .select('vacation_rule, vacation_days_per_year, salary_type, monthly_salary, hourly_rate, hours_per_week, workdays_per_week') + .eq('id', employeeId) + .eq('company_id', companyId) + .maybeSingle() + if (empErr) throw new Error(`Database error: ${empErr.message}`) + if (!employee) throw new Error(`Employee ${employeeId} not found for this company`) + const savedTotal = Object.values((rest.saved_days as Record | null) ?? {}) + .reduce((s, d) => s + (Number(d) || 0), 0) + const liabilityDays = Math.max(0, remaining + savedTotal) + const estimatedLiability = roundOre(liabilityDays * dayValueSek(employee as DayValueEmployee)) + return { employee_vacation_balance_id: id, ...rest, - remaining_days: roundOre(entitled - taken), + remaining_days: remaining, + estimated_liability_sek: estimatedLiability, } }, }, @@ -15791,7 +15815,7 @@ export const tools: McpTool[] = [ .eq('id', id).eq('company_id', companyId).single() if (!inv) throw new Error('Invoice not found') if (inv.document_type !== 'proforma') throw new Error('Endast proformafakturor kan konverteras') - if (inv.status === 'cancelled') throw new Error('Denna proformafaktura har redan makuleras') + if (inv.status === 'cancelled') throw new Error('Denna proformafaktura har redan makulerats') const customerName = (inv.customer as { name?: string } | null)?.name ?? 'okänd kund' return stagePendingOperation(supabase, companyId, userId, 'convert_invoice', @@ -16536,7 +16560,7 @@ export const tools: McpTool[] = [ if (inbox.created_journal_entry_id) { throw new Error( `Inbox item is already booked as journal entry ${inbox.created_journal_entry_id}. ` + - 'Use gnubok_correct_entry or gnubok_reverse_entry if it needs to be changed.' + 'Use gnubok_correct_entry or gnubok_reverse_journal_entry if it needs to be changed.' ) } if (inbox.created_supplier_invoice_id) { @@ -16859,7 +16883,7 @@ export const tools: McpTool[] = [ additionalProperties: false, properties: { entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' }, - reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to today (Swedish timezone). Period attribution always follows the original entry, regardless of this date.' }, + reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to the original entry date. Period attribution always follows the original entry, regardless of this date.' }, reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason: shown in pending_operations review. Not stored on the storno itself. Max 500 chars.' }, allow_deep_chain: { type: 'boolean', @@ -18434,17 +18458,24 @@ assertRecommendedLoadoutsValid(new Set(tools.map((t) => t.name))) // ── MCP Protocol Handler ───────────────────────────────────── +// Build identifier so MCP clients can tell deploys apart in `initialize` +// serverInfo. Resolved once at module load (constant per process, so the +// definitions layer stays deterministic); the same identifier /api/health +// and the behandlingshistorik report stamp. '1.0.0' is the self-hosted +// fallback when no commit SHA is inlined at build. +const SERVER_VERSION = currentAppVersion() ?? '1.0.0' + const SERVER_INFO_BY_NAMESPACE = { gnubok: { // Stable legacy identity for every existing connection. name: 'gnubok', title: 'Accounted', - version: '1.0.0', + version: SERVER_VERSION, }, accounted: { name: 'accounted', title: 'Accounted', - version: '1.0.0', + version: SERVER_VERSION, }, } as const diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts index 38854c6b..1ee974a1 100644 --- a/lib/entitlements/keys.ts +++ b/lib/entitlements/keys.ts @@ -82,8 +82,9 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [ * The document upload tools invoke AI (Bedrock document OCR via * extractInvoiceFields), so they are gated on CAPABILITY.ai: the same paywall * the HTTP inbox upload/attach/retry paths enforce. Without these entries a - * free-tier API key could trigger paid AI extraction. bank_sync has no MCP - * tool (bank sync is cron/HTTP only). + * free-tier API key could trigger paid AI extraction. bank_sync gates only + * gnubok_connect_bank (the onboarding connect link); the sync itself is + * cron/HTTP only. */ export const MCP_TOOL_CAPABILITY_MAP: Readonly>> = { gnubok_send_invoice: CAPABILITY.email_send, diff --git a/lib/salary/semesterberedning.ts b/lib/salary/semesterberedning.ts index c898aba2..bfc2b76b 100644 --- a/lib/salary/semesterberedning.ts +++ b/lib/salary/semesterberedning.ts @@ -118,7 +118,19 @@ function lastDayBefore(dateIso: string): string { return d.toISOString().slice(0, 10) } -function dayValueSek(emp: EmployeeRosterRow): number { +/** The employee master fields the day valuation reads. */ +export type DayValueEmployee = Pick< + EmployeeRosterRow, + 'vacation_rule' | 'vacation_days_per_year' | 'salary_type' | 'monthly_salary' | 'hourly_rate' | 'hours_per_week' | 'workdays_per_week' +> + +/** + * Simplified BFNAR 2016:10 value of one vacation day in SEK. Shared by the + * year-close and the MCP vacation-balance tool; the v1 vacation-balance + * route carries the same formula inline. All three must agree on the + * semesterlöneskuld estimate. + */ +export function dayValueSek(emp: DayValueEmployee): number { const rate = emp.vacation_days_per_year >= 30 ? 0.144 : 0.12 if (emp.salary_type === 'hourly') { const annualBasis = (emp.hourly_rate || 0) * (emp.hours_per_week ?? 40) * 52