diff --git a/CLAUDE.md b/CLAUDE.md index af7fe5b1..2d1570eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ npm run setup:extensions # Regenerate extension registry from extensions.config. - **All journal entry creation** routes through `lib/bookkeeping/engine.ts` via `createJournalEntry()`. - **API routes** that emit events must call `ensureInitialized()` (from `lib/init.ts`) at module level. - **Event bus** (`lib/events/bus.ts`) is a module-level singleton. Handlers run via `Promise.allSettled`. -- **Supabase clients**: browser (`lib/supabase/client.ts`), server with cookies (`createClient()` from `server.ts`), service role (`createServiceClient()`). +- **Supabase clients**: browser (`lib/supabase/client.ts`), server with cookies (`createClient()` from `server.ts`), service role (`createServiceClient()`), cookieless service role for API key auth (`createServiceClientNoCookies()` from `lib/auth/api-keys.ts`). - **Extension system**: Opt-in via `extensions.config.json`. Core builds and runs with zero extensions. - **NE-bilaga, INK2 declaration, SRU export, and full archive export** are core reports (in `lib/reports/`), not extensions. - **AI consent gate** (`lib/extensions/ai-consent.ts`): AI extensions (`receipt-ocr`, `ai-categorization`, `ai-chat`) require user consent before API calls. Returns `403 AI_CONSENT_REQUIRED` if missing. @@ -111,6 +111,29 @@ Extensions are opt-in plugins in `extensions/general//`, controlled by `ex --- +## MCP Server & API Keys + +gnubok exposes its bookkeeping engine as an MCP (Model Context Protocol) server, letting users do bookkeeping through Claude Desktop, Claude Code, or any MCP-compatible client. + +**MCP extension** (`extensions/general/mcp-server/`): 10 tools — transactions, categorization, customers, invoices, trial balance, VAT report, KPI report, income statement. JSON-RPC 2.0 protocol implemented directly (no SDK dependency). Endpoint: `/api/extensions/ext/mcp-server/mcp`. + +**API key infrastructure** (`lib/auth/api-keys.ts`, `api_keys` table): SHA-256 hashed keys with `gnubok_sk_` prefix. Rate limited at 100 RPM via atomic DB RPC (`validate_and_increment_api_key`). `createServiceClientNoCookies()` creates a Supabase service client without cookies for API key auth — all queries filter by `user_id` (defense in depth). + +**OAuth 2.1** for Claude Desktop connectors (beta — Claude's callback has a known issue, see #78): +- `.well-known/oauth-protected-resource` and `.well-known/oauth-authorization-server` — discovery endpoints (excluded from auth middleware) +- `/api/mcp-oauth/authorize` — consent page + auth code generation +- `/api/mcp-oauth/token` — PKCE verification + API key creation +- `/api/mcp-oauth/register` — dynamic client registration +- Stateless encrypted auth codes (AES-256-GCM via `lib/auth/oauth-codes.ts`) +- Single-use enforcement via `oauth_used_codes` table +- Redirect URI allowlist: `claude.ai/api/*`, `claude.com/api/*`, `localhost` + +**npm package** (`packages/gnubok-mcp`): Published as `gnubok-mcp` on npm. Stdio-to-HTTP bridge for Claude Desktop. Users configure `npx gnubok-mcp` with their API key. + +**KPI page** (`/kpi`): 4 metrics (Resultat, Kassa, Kundfordringar, Moms) + monthly trend chart. API at `/api/reports/kpi`. + +--- + ## API Route Pattern ```typescript @@ -153,7 +176,7 @@ export async function POST(request: Request) { ## Database & Migrations -**Location**: `supabase/migrations/` — 65 files. Early migrations use sequential numbering (`20240101000001`–`20240101000038`), later ones use real timestamps. +**Location**: `supabase/migrations/` — 70 files. Early migrations use sequential numbering (`20240101000001`–`20240101000038`), later ones use real timestamps. ### Migration Rules diff --git a/app/(dashboard)/kpi/page.tsx b/app/(dashboard)/kpi/page.tsx index 70d49f86..4f9baf50 100644 --- a/app/(dashboard)/kpi/page.tsx +++ b/app/(dashboard)/kpi/page.tsx @@ -4,7 +4,6 @@ import { useState, useEffect } from 'react' import { Label } from '@/components/ui/label' import { Card, CardContent } from '@/components/ui/card' import { KPIHeroCards } from '@/components/kpi/KPIHeroCards' -import { KPIOperationalGrid } from '@/components/kpi/KPIOperationalGrid' import { KPITrendChart } from '@/components/kpi/KPITrendChart' import type { FiscalPeriod, KPIReport } from '@/types' @@ -108,7 +107,6 @@ export default function KpiPage() { {!isLoadingReport && !error && report && ( <> - {report.months.length > 0 && } )} @@ -138,16 +136,6 @@ function LoadingSkeleton() { ))} -
- {[1, 2, 3, 4].map((i) => ( - - -
-
- - - ))} -
diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts index 9dca8237..21bb981d 100644 --- a/app/api/reports/kpi/route.ts +++ b/app/api/reports/kpi/route.ts @@ -4,13 +4,7 @@ import { generateIncomeStatement } from '@/lib/reports/income-statement' import { generateTrialBalance } from '@/lib/reports/trial-balance' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' -import { - calculateGrossMargin, - calculateCashPosition, - calculateRevenueGrowth, - calculateExpenseRatio, - calculateAvgPaymentDays, -} from '@/lib/reports/kpi' +import { calculateCashPosition } from '@/lib/reports/kpi' import type { KPIReport } from '@/types' export async function GET(request: Request) { @@ -24,7 +18,6 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) } - // Fetch fiscal period info const { data: period, error: periodError } = await supabase .from('fiscal_periods') .select('*') @@ -36,31 +29,15 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) } - // Run independent queries in parallel - const [ - incomeStatement, - trialBalanceResult, - arLedger, - monthlyBreakdown, - paidInvoicesResult, - ] = await Promise.all([ - generateIncomeStatement(supabase, user.id, periodId), - generateTrialBalance(supabase, user.id, periodId), - generateARLedger(supabase, user.id), - generateMonthlyBreakdown(supabase, user.id, periodId), - supabase - .from('invoices') - .select('invoice_date, paid_at') - .eq('user_id', user.id) - .eq('status', 'paid') - .not('paid_at', 'is', null) - .gte('invoice_date', period.period_start) - .lte('invoice_date', period.period_end), - ]) + const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown] = + await Promise.all([ + generateIncomeStatement(supabase, user.id, periodId), + generateTrialBalance(supabase, user.id, periodId), + generateARLedger(supabase, user.id), + generateMonthlyBreakdown(supabase, user.id, periodId), + ]) - const paidInvoices = (paidInvoicesResult.data || []) as { invoice_date: string; paid_at: string }[] - - // Calculate VAT liability from trial balance (output VAT - input VAT) + // VAT liability from trial balance (output VAT - input VAT) const vatOutputAccounts = ['2611', '2621', '2631'] const vatInputAccounts = ['2641', '2645'] const outputVat = trialBalanceResult.rows @@ -69,33 +46,13 @@ export async function GET(request: Request) { const inputVat = trialBalanceResult.rows .filter((r) => vatInputAccounts.includes(r.account_number)) .reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) - const vatLiability = Math.round((outputVat - inputVat) * 100) / 100 - - // Revenue growth: only for closed periods, compare with previous period - let revenueGrowth: number | null = null - if (period.is_closed && period.previous_period_id) { - const prevStatement = await generateIncomeStatement( - supabase, - user.id, - period.previous_period_id - ) - revenueGrowth = calculateRevenueGrowth( - incomeStatement.total_revenue, - prevStatement.total_revenue - ) - } const report: KPIReport = { - grossMargin: calculateGrossMargin(incomeStatement), netResult: incomeStatement.net_result, cashPosition: calculateCashPosition(trialBalanceResult.rows), outstandingReceivables: arLedger.total_outstanding, overdueReceivables: arLedger.total_overdue, - revenueGrowth, - expenseRatio: calculateExpenseRatio(incomeStatement), - avgPaymentDays: calculateAvgPaymentDays(paidInvoices), - paidInvoiceCount: paidInvoices.length, - vatLiability, + vatLiability: Math.round((outputVat - inputVat) * 100) / 100, totalRevenue: incomeStatement.total_revenue, totalExpenses: incomeStatement.total_expenses, periodComplete: period.is_closed, diff --git a/components/kpi/KPIHeroCards.tsx b/components/kpi/KPIHeroCards.tsx index 05912ee8..56853657 100644 --- a/components/kpi/KPIHeroCards.tsx +++ b/components/kpi/KPIHeroCards.tsx @@ -11,17 +11,6 @@ interface KPIHeroCardsProps { export function KPIHeroCards({ report }: KPIHeroCardsProps) { return (
- {/* Gross margin */} - - -

Bruttomarginal

-

- {report.grossMargin !== null ? `${report.grossMargin}%` : '—'} -

-

av intäkter

-
-
- {/* Net result */} @@ -64,6 +53,21 @@ export function KPIHeroCards({ report }: KPIHeroCardsProps) { )} + + {/* VAT liability */} + + +

Moms

+

0 ? 'text-[hsl(var(--chart-2))]' : 'text-[hsl(var(--chart-1))]' + }`}> + {formatCurrency(Math.abs(report.vatLiability))} +

+

+ {report.vatLiability > 0 ? 'att betala' : report.vatLiability < 0 ? 'att återfå' : 'jämnt'} +

+
+
) } diff --git a/components/kpi/KPIOperationalGrid.tsx b/components/kpi/KPIOperationalGrid.tsx deleted file mode 100644 index 5e95e109..00000000 --- a/components/kpi/KPIOperationalGrid.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client' - -import { Card, CardContent } from '@/components/ui/card' -import { TrendingUp, TrendingDown, Info } from 'lucide-react' -import { formatCurrency } from '@/lib/utils' -import type { KPIReport } from '@/types' - -interface KPIOperationalGridProps { - report: KPIReport -} - -export function KPIOperationalGrid({ report }: KPIOperationalGridProps) { - return ( -
- {/* Revenue growth */} - - -

Intäktstillväxt

- {!report.periodComplete ? ( -

Välj ett avslutat räkenskapsår

- ) : report.revenueGrowth !== null ? ( -
- {report.revenueGrowth >= 0 ? ( - - ) : ( - - )} -

= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]' - }`}> - {report.revenueGrowth > 0 ? '+' : ''}{report.revenueGrowth}% -

-
- ) : ( -

Första räkenskapsåret

- )} -
-
- - {/* Expense ratio */} - - -

Kostnadsandel

- {report.expenseRatio !== null ? ( - <> -

- {report.expenseRatio}% -

-
-
-
- - ) : ( -

Inga intäkter

- )} - - - - {/* Avg payment days */} - - -
-

Snittbetaltid

- {report.avgPaymentDays === null && ( - - - - )} -
- {report.avgPaymentDays !== null ? ( -

- {report.avgPaymentDays} dagar -

- ) : ( -

Inte tillräckligt med data

- )} -
-
- - {/* VAT liability */} - - -

Momsskuld

-

0 ? 'text-[hsl(var(--chart-2))]' : 'text-[hsl(var(--chart-1))]' - }`}> - {formatCurrency(Math.abs(report.vatLiability))} -

-

- {report.vatLiability > 0 ? 'Att betala' : report.vatLiability < 0 ? 'Att återfå' : 'Jämnt'} -

-
-
-
- ) -} diff --git a/types/index.ts b/types/index.ts index 0c5e4939..5060423b 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1999,15 +1999,10 @@ export interface VatBreakdownItem { // KPI Report export interface KPIReport { - grossMargin: number | null // percentage, null if no revenue netResult: number // SEK cashPosition: number // SEK (sum of 19xx account balances) outstandingReceivables: number // SEK overdueReceivables: number // SEK - revenueGrowth: number | null // percentage, null if no prior period or current period incomplete - expenseRatio: number | null // percentage, null if no revenue - avgPaymentDays: number | null // days, null if < 5 paid invoices with paid_at - paidInvoiceCount: number // how many invoices had paid_at data (for gating) vatLiability: number // SEK, ruta 49 (positive = owe, negative = refund) totalRevenue: number // SEK totalExpenses: number // SEK