Files
accounted/lib/auth/api-keys.ts
T
Mattsson 04dbb31d7e Salary module (#245)
* feat: implement salary module with personnummer encryption, salary entries, tax tables, and AGI tracking

- Added personnummer encryption and decryption functions for secure storage.
- Created salary entries handling for journal entries including gross salary, tax withholding, and employer contributions.
- Implemented tax table lookup functionality for calculating tax amounts based on monthly income.
- Developed SQL migration for salary module including tables for payroll configuration, tax rates, employees, salary runs, salary run employees, salary line items, and AGI declarations.
- Established row-level security policies for all new tables to ensure company-scoped access.

* feat: add salary calculation modules for 2026

- Implemented engångsskatt calculation for one-time payments with tax brackets.
- Added löneväxling functionality for salary sacrifice to pension, including employer savings and warnings.
- Created pain.001 generator for salary batch payments in compliance with Swedish banking standards.
- Developed PDF template for payslips, including detailed breakdowns and employer costs.
- Generated seed data for Swedish tax tables for 2026, including SQL insert statements.
- Implemented traktamente calculations for per diem and mileage allowances, adhering to Skatteverket regulations.
- Added seed script for populating tax tables in the database.

* feat: Update meal reduction percentages in traktamente calculation

fix: Remove obsolete seed script for 2026 tax tables

feat: Extend SalaryRunStatus type to include 'corrected' status

feat: Implement KU10 XML generation endpoint for annual employee income statements

feat: Add endpoint for creating corrections to booked salary runs

feat: Implement endpoint for sending payslip PDFs to employees

feat: Create KU10 XML generator for annual reporting

feat: Add salary transaction matcher for auto-linking bank transactions to salary entries

chore: Add database migration for salary correction support

* feat: replace select elements with custom Select component for employment and salary types

* feat: enhance salary calculations with pension entry and avgifter category support
2026-04-15 11:17:39 +02:00

169 lines
7.4 KiB
TypeScript

import crypto from 'crypto'
import { createClient } from '@supabase/supabase-js'
const KEY_PREFIX = 'gnubok_sk_'
// ── API Key Scopes ──────────────────────────────────────────
export const API_KEY_SCOPES = {
'transactions:read': { label: 'Transaktioner — läs', description: 'Lista transaktioner, mallförslag, kategoriförslag (3 verktyg)' },
'transactions:write': { label: 'Transaktioner — skriv', description: 'Kategorisera, kvittomatchning, koppling mot faktura (3 verktyg)' },
'customers:read': { label: 'Kunder — läs', description: 'Lista kunder (1 verktyg)' },
'customers:write': { label: 'Kunder — skriv', description: 'Skapa kunder (1 verktyg)' },
'invoices:read': { label: 'Fakturor — läs', description: 'Lista fakturor (1 verktyg)' },
'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' },
'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor (2 verktyg)' },
'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning (11 verktyg)' },
'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
'payroll:write': { label: 'Löner — skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
} as const
export type ApiKeyScope = keyof typeof API_KEY_SCOPES
export const ALL_SCOPES: ApiKeyScope[] = Object.keys(API_KEY_SCOPES) as ApiKeyScope[]
/** The read-only scopes assigned to keys with no explicit scopes (legacy/null). */
export const DEFAULT_SCOPES: ApiKeyScope[] = [
'transactions:read',
'customers:read',
'invoices:read',
'suppliers:read',
'reports:read',
]
/** Scope domain groups for UI rendering */
export const SCOPE_GROUPS = [
{ domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const },
{ domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const },
{ domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const },
{ domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: null },
{ domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null },
{ domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const },
] as const
/** Map MCP tool name → required scope */
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
// Transactions
gnubok_list_uncategorized_transactions: 'transactions:read',
gnubok_categorize_transaction: 'transactions:write',
gnubok_receipt_matcher: 'transactions:write',
gnubok_get_counterparty_templates: 'transactions:read',
gnubok_suggest_categories: 'transactions:read',
gnubok_match_transaction_to_invoice: 'transactions:write',
// Customers
gnubok_list_customers: 'customers:read',
gnubok_create_customer: 'customers:write',
// Invoices
gnubok_list_invoices: 'invoices:read',
gnubok_create_invoice: 'invoices:write',
gnubok_send_invoice: 'invoices:write',
gnubok_mark_invoice_as_paid: 'invoices:write',
gnubok_mark_invoice_as_sent: 'invoices:write',
// Suppliers
gnubok_list_suppliers: 'suppliers:read',
gnubok_list_supplier_invoices: 'suppliers:read',
// Reports
gnubok_get_trial_balance: 'reports:read',
gnubok_get_vat_report: 'reports:read',
gnubok_get_kpi_report: 'reports:read',
gnubok_get_income_statement: 'reports:read',
gnubok_list_accounts: 'reports:read',
gnubok_get_balance_sheet: 'reports:read',
gnubok_get_general_ledger: 'reports:read',
gnubok_get_ar_ledger: 'reports:read',
gnubok_get_supplier_ledger: 'reports:read',
gnubok_list_fiscal_periods: 'reports:read',
gnubok_get_reconciliation_status: 'reports:read',
// Document inbox
gnubok_upload_document: 'transactions:write',
gnubok_list_inbox_items: 'transactions:read',
gnubok_get_inbox_item: 'transactions:read',
// Payroll
gnubok_list_employees: 'payroll:read',
gnubok_get_salary_run: 'payroll:read',
gnubok_get_salary_journal: 'payroll:read',
gnubok_create_salary_run: 'payroll:write',
gnubok_calculate_salary_run: 'payroll:write',
gnubok_generate_agi: 'payroll:write',
}
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
if (scopes === null || scopes === undefined) return null
if (!Array.isArray(scopes)) return null
const valid = scopes.filter((s): s is ApiKeyScope => s in API_KEY_SCOPES)
return valid.length > 0 ? valid : null
}
/**
* Create a Supabase service client that doesn't require cookies.
* Used for API key validation (MCP, webhooks) where there's no browser session.
*/
export function createServiceClientNoCookies() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
}
export function generateApiKey(): { key: string; hash: string; prefix: string } {
const random = crypto.randomBytes(32).toString('base64url')
const key = `${KEY_PREFIX}${random}`
const hash = hashApiKey(key)
const prefix = key.slice(0, KEY_PREFIX.length + 8)
return { key, hash, prefix }
}
export function hashApiKey(key: string): string {
return crypto.createHash('sha256').update(key).digest('hex')
}
export function extractBearerToken(request: Request): string | null {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) return null
return authHeader.slice(7)
}
/**
* Validate an API key and enforce rate limiting.
* Uses the DB RPC for atomic check + increment.
* Returns the user_id and effective scopes on success, or an error with HTTP status.
* null scopes in DB → DEFAULT_SCOPES (read-only).
*/
export async function validateApiKey(
key: string
): Promise<{ userId: string; companyId: string; scopes: ApiKeyScope[] } | { error: string; status: number }> {
if (!key.startsWith(KEY_PREFIX)) {
return { error: 'Invalid API key format', status: 401 }
}
const hash = hashApiKey(key)
const supabase = createServiceClientNoCookies()
const { data, error } = await supabase.rpc('validate_and_increment_api_key', {
p_key_hash: hash,
})
if (error || !data || data.length === 0) {
return { error: 'Invalid API key', status: 401 }
}
const row = data[0]
if (row.rate_limited) {
return { error: 'Rate limit exceeded', status: 429 }
}
return {
userId: row.user_id,
companyId: row.company_id,
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
}
}
/**
* Check if a given scope is allowed by the key's scopes.
*/
export function hasScope(keyScopes: ApiKeyScope[], required: ApiKeyScope): boolean {
return keyScopes.includes(required)
}