Files
accounted/lib/reports/kpi.ts
T
Jakob Wennberg 5d66dd6bfc feat: MCP server, API keys, OAuth, and KPI dashboard (#72)
* fix: prevent Chrome auto-translate from crashing React during onboarding

Chrome auto-translate modifies DOM text nodes when it detects a Swedish
page (lang="sv") in a browser set to English. React does not expect
external DOM mutations and throws, crashing the entire component tree
into global-error.tsx on every step transition.

Add translate="no" and <meta name="google" content="notranslate"> to
suppress browser translation. Also fix timezone-unsafe date parsing in
fiscal period validation (new Date("YYYY-MM-DD") + getDate() returns
local-timezone values, shifting dates by -1 day in Western timezones).

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

* fix: add notranslate meta tag to global-error.tsx for consistency

Per review feedback — global-error.tsx renders its own <html> document,
so it needs the same <meta name="google" content="notranslate"> tag as
layout.tsx to fully suppress Chrome translation on error pages.

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

* feat: add MCP server extension with OAuth, API keys, and KPI dashboard

Let users do bookkeeping through Claude Desktop, Claude Code, or any
MCP-compatible client. "Show my uncategorized transactions." "Book that
as office supplies." "Invoice Acme for 15,000 kr."

MCP server (extension):
- 10 tools: transactions, categorization, customers, invoices,
  trial balance, VAT report, KPI report, income statement
- JSON-RPC 2.0 protocol (no SDK dependency, works in serverless)
- Tool annotations, pagination, input validation per MCP best practices
- Same engine as web UI (VAT rules, exchange rates, event emission)

API key infrastructure (core):
- api_keys table with RLS, rate limiting (100 RPM), scopes column
- Atomic rate limit via DB RPC (validate_and_increment_api_key)
- Key management API routes + settings UI panel

OAuth 2.1 for Claude Desktop connectors:
- .well-known/oauth-protected-resource + oauth-authorization-server
- Authorization endpoint with consent page
- Token endpoint with PKCE verification
- Stateless encrypted auth codes (AES-256-GCM, no DB storage)
- Dynamic client registration

KPI dashboard:
- /nyckeltal page with hero cards, operational grid, trend chart
- GET /api/reports/kpi endpoint
- Gross margin, cash position, expense ratio, avg payment days,
  VAT liability, revenue/expense trend

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

* fix: address OAuth security vulnerabilities from code review

Critical fixes:
- Auth code replay: Track used codes in oauth_used_codes table with
  unique constraint. Codes are single-use per OAuth 2.1 §4.1.2.
- Open redirect: Validate redirect_uri against hardcoded allowlist
  of known Claude callback URLs + localhost for dev.

P1 fixes:
- Move API key creation from /authorize to /token endpoint. Keys are
  only created after PKCE verification, preventing orphaned keys on
  abandoned OAuth flows.
- Add ensureInitialized() to MCP server so event handlers load and
  transaction.categorized events reach extensions.

P2 fixes:
- Remove 'plain' from PKCE methods — only S256 is advertised and
  accepted.
- Fix extension count in sectors test (10 → 11 for mcp-server).

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

* fix: remove duplicate ensureInitialized() that caused circular import

The extension router (ext/[...path]/route.ts) already calls
ensureInitialized() before dispatching to handlers. The duplicate
call in server.ts created a circular import that Turbopack couldn't
resolve, breaking the Vercel build.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:57:14 +01:00

75 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IncomeStatementReport, TrialBalanceRow } from '@/types'
/**
* Calculate gross margin from income statement.
* Gross margin = (revenue - COGS) / revenue × 100
* COGS = class 4 expense sections (Varor och material, etc.)
*/
export function calculateGrossMargin(incomeStatement: IncomeStatementReport): number | null {
const { total_revenue, expense_sections } = incomeStatement
if (total_revenue === 0) return null
// Class 4 expenses = cost of goods sold (account prefixes 40-49)
const cogs = expense_sections
.filter((s) => s.rows.some((r) => r.account_number.startsWith('4')))
.reduce((sum, s) => sum + s.subtotal, 0)
return Math.round(((total_revenue - cogs) / total_revenue) * 10000) / 100
}
/**
* Calculate cash position from trial balance rows.
* Sums closing balances for accounts matching 19xx (bank + cash accounts).
*/
export function calculateCashPosition(rows: TrialBalanceRow[]): number {
const cashRows = rows.filter((r) => r.account_number.startsWith('19'))
const total = cashRows.reduce(
(sum, r) => sum + (r.closing_debit - r.closing_credit),
0
)
return Math.round(total * 100) / 100
}
/**
* Calculate revenue growth between two periods.
* Returns percentage or null if no previous period data.
*/
export function calculateRevenueGrowth(
currentRevenue: number,
previousRevenue: number | null
): number | null {
if (previousRevenue === null || previousRevenue === 0) return null
return Math.round(((currentRevenue - previousRevenue) / previousRevenue) * 10000) / 100
}
/**
* Calculate expense ratio from income statement.
* Expense ratio = total_expenses / total_revenue × 100
*/
export function calculateExpenseRatio(incomeStatement: IncomeStatementReport): number | null {
const { total_revenue, total_expenses } = incomeStatement
if (total_revenue === 0) return null
return Math.round((total_expenses / total_revenue) * 10000) / 100
}
/**
* Calculate average payment days from paid invoices.
* Returns null if fewer than 5 invoices with paid_at data.
*/
export function calculateAvgPaymentDays(
paidInvoices: { invoice_date: string; paid_at: string }[]
): number | null {
if (paidInvoices.length < 5) return null
const totalDays = paidInvoices.reduce((sum, inv) => {
const invoiceDate = new Date(inv.invoice_date)
const paidDate = new Date(inv.paid_at)
const days = Math.floor(
(paidDate.getTime() - invoiceDate.getTime()) / (1000 * 60 * 60 * 24)
)
return sum + Math.max(0, days)
}, 0)
return Math.round(totalDays / paidInvoices.length)
}