diff --git a/app/api/settings/oauth-clients/route.ts b/app/api/settings/oauth-clients/route.ts index d6eed189..a8428c52 100644 --- a/app/api/settings/oauth-clients/route.ts +++ b/app/api/settings/oauth-clients/route.ts @@ -15,17 +15,17 @@ import { z } from 'zod' const RegistrationSchema = z.object({ client_name: z.string().trim().min(1).max(100), - // Require https for non-loopback URIs. We reject loopback here because - // localhost is already on the built-in allowlist — there's no reason to - // register it explicitly. + // Reject loopback first (covers http:// too) so the user gets the helpful + // "already allowed" message instead of being told to use https for localhost. + // Non-loopback URIs must use https. redirect_uri: z .string() .url('redirect_uri must be a valid URL') - .refine((u) => u.startsWith('https://'), 'redirect_uri must use https://') .refine( - (u) => !/^https:\/\/(localhost|127\.0\.0\.1|::1)(:|\/|$)/i.test(u), - 'localhost is already allowed without registration' + (u) => !/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|::1)(:|\/|$)/i.test(u), + 'localhost är redan tillåtet utan registrering' ) + .refine((u) => u.startsWith('https://'), 'redirect_uri måste använda https://') .max(500), }) @@ -60,10 +60,13 @@ export async function POST(request: Request) { const json = await request.json() body = RegistrationSchema.parse(json) } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Invalid request body' }, - { status: 400 } - ) + const message = + err instanceof z.ZodError + ? err.issues[0]?.message ?? 'Ogiltig redirect URI' + : err instanceof SyntaxError + ? 'Ogiltig JSON i request body' + : 'Ogiltig redirect URI' + return NextResponse.json({ error: message }, { status: 400 }) } const { data, error } = await supabase diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index f0011feb..c8c01512 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -190,11 +190,7 @@ interface ConnectionStatus { } } -const COMING_SOON_PROVIDERS = new Set( - process.env.NODE_ENV === 'development' - ? ['bjornlunden', 'briox'] - : ['visma', 'bjornlunden', 'briox'] -) +const COMING_SOON_PROVIDERS = new Set(['bjornlunden', 'briox']) const PROVIDER_LOGOS: Record = { fortnox: '/logos/fortnox.svg', diff --git a/components/settings/OAuthClientsPanel.tsx b/components/settings/OAuthClientsPanel.tsx index a5087f9e..2f2a87f1 100644 --- a/components/settings/OAuthClientsPanel.tsx +++ b/components/settings/OAuthClientsPanel.tsx @@ -190,7 +190,12 @@ export function OAuthClientsPanel() { Registrera redirect URI - Måste vara en exakt URL som börjar med https://. Den jämförs sedan ord-för-ord mot + Bara för egenbyggda MCP-klienter med en publik HTTPS-callback. Lägg{' '} + inte till{' '} + localhost{' '} + eller{' '} + claude.ai{' '} + — de fungerar redan utan registrering. URI:n jämförs ord-för-ord mot redirect_uri-parametern i OAuth-flödet. diff --git a/extensions/general/mcp-server/__tests__/create-supplier.test.ts b/extensions/general/mcp-server/__tests__/create-supplier.test.ts new file mode 100644 index 00000000..dde9e25e --- /dev/null +++ b/extensions/general/mcp-server/__tests__/create-supplier.test.ts @@ -0,0 +1,191 @@ +/** + * Unit tests for gnubok_create_supplier — registration, risk tier, and + * input validation (ASVS V2.3, V4.5; ISO A.8.28; CC6.3). + * + * The financial identifier checks here guard against the supplier-fraud / + * BEC risk surface flagged in the PR compliance review: malformed IBAN, + * BIC, bankgiro, org_number, or VAT number must be rejected before the + * operation is staged, and an explicit default_payment_terms of 0 must + * NOT be silently rewritten to 30 days. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { tools } from '../server' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' +import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers' + +vi.mock('@/lib/currency/riksbanken', () => ({ + fetchExchangeRate: vi.fn().mockResolvedValue(11.5), + convertToSEK: vi.fn(), +})) + +const tool = () => tools.find((t) => t.name === 'gnubok_create_supplier')! + +describe('gnubok_create_supplier — registration', () => { + it('is registered with idempotent + non-read-only annotations', () => { + expect(tool()).toBeDefined() + expect(tool().annotations.readOnlyHint).toBe(false) + expect(tool().annotations.idempotentHint).toBe(true) + expect(tool().annotations.destructiveHint).toBe(false) + }) + + it('declares additionalProperties: false on its inputSchema', () => { + const schema = tool().inputSchema as { additionalProperties?: boolean } + expect(schema.additionalProperties).toBe(false) + }) + + it('only requires `name`', () => { + const schema = tool().inputSchema as { required?: string[] } + expect(schema.required).toEqual(['name']) + }) + + it('is mapped to suppliers:write scope', () => { + expect(TOOL_SCOPE_MAP.gnubok_create_supplier).toBe('suppliers:write') + }) + + it('is classified as medium risk (carries payment-routing fields)', () => { + expect(OPERATION_RISK_TIERS.create_supplier).toBe('medium') + }) + + it('inputSchema constrains name maxLength and supplier_type enum', () => { + const props = (tool().inputSchema as { properties: Record }).properties + expect(props.name.maxLength).toBe(255) + expect(props.supplier_type.enum).toEqual(['swedish_business', 'eu_business', 'non_eu_business']) + }) +}) + +/** + * Validation tests below exercise the Zod schema via tool.execute(). All + * inputs are rejected before any supabase call, so we pass an inert stub. + */ +const noopSupabase = { + from: vi.fn(() => ({ + insert: vi.fn(() => ({ select: vi.fn(() => ({ single: vi.fn() })) })), + })), +} as never + +describe('gnubok_create_supplier — input validation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects empty name', async () => { + await expect( + tool().execute({ name: ' ' }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/name/i) + }) + + it('rejects name longer than 255 chars', async () => { + await expect( + tool().execute({ name: 'A'.repeat(256) }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/name/i) + }) + + it('rejects malformed IBAN', async () => { + await expect( + tool().execute({ name: 'Acme', iban: 'NOT-AN-IBAN' }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/iban/i) + }) + + it('rejects malformed BIC', async () => { + await expect( + tool().execute({ name: 'Acme', bic: 'abc' }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/bic/i) + }) + + it('rejects bankgiro with invalid Luhn check digit', async () => { + await expect( + tool().execute({ name: 'Acme', bankgiro: '1234567' }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/bankgiro/i) + }) + + it('rejects malformed Swedish org_number', async () => { + await expect( + tool().execute({ name: 'Acme', org_number: '12345' }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/org_number/i) + }) + + it('rejects malformed EU VAT number', async () => { + await expect( + tool().execute({ name: 'Acme', vat_number: 'XX123' }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/vat_number/i) + }) + + it('rejects default_expense_account outside BAS class 4-7', async () => { + await expect( + tool().execute({ name: 'Acme', default_expense_account: '1930' }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/default_expense_account/i) + }) + + it('rejects default_payment_terms over 365', async () => { + await expect( + tool().execute({ name: 'Acme', default_payment_terms: 999 }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/default_payment_terms/i) + }) + + it('requires vat_number when supplier_type is eu_business', async () => { + await expect( + tool().execute({ name: 'Acme GmbH', supplier_type: 'eu_business' }, 'company-1', 'user-1', noopSupabase), + ).rejects.toThrow(/vat_number/i) + }) +}) + +/** + * The remaining cases verify the *happy path* doesn't accidentally drop or + * mutate caller intent. We use dry_run so no DB write is attempted; the + * staging helper still receives the validated params. + */ +describe('gnubok_create_supplier — staging behaviour', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('preserves default_payment_terms=0 (due-on-receipt)', async () => { + const result = await tool().execute( + { name: 'SameDay AB', default_payment_terms: 0, dry_run: true }, + 'company-1', + 'user-1', + noopSupabase, + ) as { preview?: { default_payment_terms?: number }; dry_run?: boolean } + expect(result.dry_run).toBe(true) + expect(result.preview?.default_payment_terms).toBe(0) + }) + + it('defaults missing default_payment_terms to 30', async () => { + const result = await tool().execute( + { name: 'Acme AB', dry_run: true }, + 'company-1', + 'user-1', + noopSupabase, + ) as { preview?: { default_payment_terms?: number } } + expect(result.preview?.default_payment_terms).toBe(30) + }) + + it('defaults supplier_type to swedish_business', async () => { + const result = await tool().execute( + { name: 'Acme AB', dry_run: true }, + 'company-1', + 'user-1', + noopSupabase, + ) as { preview?: { supplier_type?: string } } + expect(result.preview?.supplier_type).toBe('swedish_business') + }) + + it('accepts a valid IBAN + BIC + bankgiro', async () => { + const result = await tool().execute( + { + name: 'Acme AB', + iban: 'SE3550000000054910000003', + bic: 'NDEASESS', + bankgiro: '5050-1055', + dry_run: true, + }, + 'company-1', + 'user-1', + noopSupabase, + ) as { preview?: { iban?: string; bic?: string; bankgiro?: string }; dry_run?: boolean } + expect(result.dry_run).toBe(true) + expect(result.preview?.iban).toBe('SE3550000000054910000003') + expect(result.preview?.bic).toBe('NDEASESS') + expect(result.preview?.bankgiro).toBe('5050-1055') + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index a55e84b8..72e63784 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -30,6 +30,8 @@ import { dataResources, findResource, parseResourceQuery } from './resources' import { prompts, findPrompt } from './prompts' import { skills, findSkill, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills' import { getRiskLevel } from '@/lib/pending-operations/risk-tiers' +import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier' +import { z } from 'zod' import { checkIdempotencyKey, storeIdempotencyResponse, @@ -2631,6 +2633,141 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_create_supplier', + description: 'Stage a new supplier (leverantör). Stages for user approval — NOT created until approved in the web app. Use to add a vendor before booking a supplier invoice or matching expenses.', + outputSchema: STAGED_OPERATION_SCHEMA, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string', maxLength: 255, description: 'Supplier name' }, + supplier_type: { + type: 'string', + enum: ['swedish_business', 'eu_business', 'non_eu_business'], + description: 'Supplier type (default swedish_business). eu_business requires vat_number.', + }, + email: { type: 'string', maxLength: 255, format: 'email', description: 'Email address' }, + phone: { type: 'string', maxLength: 50, description: 'Phone number' }, + org_number: { + type: 'string', + maxLength: 20, + pattern: '^\\d{6}-?\\d{4}$|^\\d{12}$', + description: 'Swedish org number (10 digits with optional hyphen XXXXXX-XXXX, or 12 digits).', + }, + vat_number: { + type: 'string', + maxLength: 20, + description: 'EU VAT number with country prefix (e.g. SE556677778800, DE123456789). Required when supplier_type is eu_business.', + }, + address_line1: { type: 'string', maxLength: 255, description: 'Street address' }, + address_line2: { type: 'string', maxLength: 255 }, + postal_code: { type: 'string', maxLength: 20 }, + city: { type: 'string', maxLength: 100 }, + country: { + type: 'string', + maxLength: 2, + pattern: '^[A-Za-z]{2}$', + description: 'ISO 3166-1 alpha-2 country code (default SE)', + }, + bankgiro: { + type: 'string', + maxLength: 20, + pattern: '^\\d{3,4}-?\\d{4}$', + description: 'Swedish Bankgiro number (7-8 digits with valid Luhn check digit).', + }, + plusgiro: { + type: 'string', + maxLength: 20, + pattern: '^\\d{1,7}-?\\d{1}$', + description: 'Swedish Plusgiro number (2-8 digits).', + }, + bank_account: { type: 'string', maxLength: 50, description: 'Bank account number' }, + iban: { + type: 'string', + maxLength: 34, + pattern: '^[A-Z]{2}\\d{2}[A-Z0-9]{11,30}$', + description: 'IBAN (ISO 13616). Country code + 2 check digits + alphanumeric.', + }, + bic: { + type: 'string', + maxLength: 11, + pattern: '^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$', + description: 'BIC/SWIFT code (8 or 11 chars).', + }, + default_expense_account: { + type: 'string', + maxLength: 10, + pattern: '^[4567]\\d{3}$', + description: '4-digit BAS expense account (class 4, 5, 6, or 7). e.g. "5010".', + }, + default_payment_terms: { + type: 'integer', + minimum: 0, + maximum: 365, + description: 'Payment terms in days (default 30). Use 0 for due-on-receipt.', + }, + default_currency: { + type: 'string', + minLength: 3, + maxLength: 3, + description: 'Default invoice currency, 3-letter ISO code (default SEK).', + }, + notes: { type: 'string', maxLength: 2000 }, + dry_run: { + type: 'boolean', + description: 'If true, validate inputs and return the would-be preview without staging or creating. No DB writes, no side-effects.', + }, + idempotency_key: { + type: 'string', + description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.', + }, + }, + required: ['name'], + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + // Server-side validation (defense in depth): MCP transport already + // checks the JSON Schema, but we re-validate with Zod so financial + // identifiers (IBAN, BIC, bankgiro Luhn, org_number, VAT format) are + // rejected at the ingestion boundary rather than persisted. + // Strip MCP control fields before parsing — the strict schema rejects + // unknown keys to satisfy ASVS V4.5 field-allow-listing. + const { dry_run, idempotency_key, ...supplierArgs } = args + let params + try { + params = CreateSupplierParamsSchema.parse(supplierArgs) + } catch (err) { + if (err instanceof z.ZodError) { + const issue = err.issues[0] + const path = issue?.path?.join('.') ?? 'params' + throw new Error(`Invalid ${path}: ${issue?.message ?? 'validation failed'}`) + } + throw err + } + + return stagePendingOperation(supabase, companyId, userId, 'create_supplier', + `Ny leverantör: ${params.name}`, + params, + params, + actor, + { + description: 'Once approved, you can book supplier invoices against this supplier with gnubok_create_supplier_invoice_from_inbox using the returned supplier_id.', + tool: 'gnubok_create_supplier_invoice_from_inbox', + }, + { + dryRun: Boolean(dry_run), + idempotencyKey: typeof idempotency_key === 'string' ? idempotency_key : undefined, + } + ) + }, + }, + { name: 'gnubok_list_supplier_invoices', description: 'List supplier invoices (leverantörsfakturor), sorted by due date. Optional status filter; "to_pay" combines approved+overdue.', @@ -6821,6 +6958,7 @@ export async function handleMcpRequest(request: Request): Promise { 'Common workflows:', '• Categorize transactions: gnubok_list_uncategorized_transactions → gnubok_suggest_categories → gnubok_categorize_transaction (stages) → gnubok_approve_pending_operation (after user confirms in chat). Use gnubok_match_transaction_to_invoice to apply income to a specific invoice.', '• Invoicing: gnubok_list_customers (or gnubok_create_customer) → gnubok_create_invoice → gnubok_send_invoice or gnubok_mark_invoice_as_sent → gnubok_mark_invoice_as_paid. Refund via gnubok_credit_invoice.', + '• Suppliers: gnubok_list_suppliers (or gnubok_create_supplier) → gnubok_create_supplier_invoice_from_inbox → gnubok_approve_supplier_invoice. Refund via gnubok_credit_supplier_invoice.', '• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative).', '• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger — all default to the most recent fiscal period.', '• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.', diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 53b42bc6..789a7b1a 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -201,6 +201,8 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_export_sie: 'reports:read', gnubok_audit_package: 'reports:read', gnubok_import_sie: 'bookkeeping:write', + // Supplier CRUD + gnubok_create_supplier: 'suppliers:write', // Supplier invoice lifecycle gnubok_approve_supplier_invoice: 'suppliers:write', gnubok_credit_supplier_invoice: 'suppliers:write', diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index e6559b14..00af5168 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -52,6 +52,8 @@ import { InvoicePDF } from '@/lib/invoices/pdf-template' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { createLogger } from '@/lib/logger' import { appendProcessingHistory } from '@/lib/processing-history/append' +import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier' +import { z } from 'zod' import type { Transaction, TransactionCategory, @@ -60,6 +62,7 @@ import type { Currency, Invoice, Customer, + Supplier, PendingOperation, CompanySettings, InvoiceItem, @@ -319,6 +322,63 @@ async function commitCreateCustomer( return { data: { customer_id: data.id } } } +async function commitCreateSupplier( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + // Defense in depth: re-validate the staged params at the commit boundary so a + // tampered pending_operations row cannot inject unexpected fields or + // malformed payment-routing data into the suppliers table (ASVS V4.5). + let validated + try { + validated = CreateSupplierParamsSchema.parse(params) + } catch (err) { + if (err instanceof z.ZodError) { + const issue = err.issues[0] + const path = issue?.path?.join('.') ?? 'params' + return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 } + } + throw err + } + + const { data, error } = await supabase + .from('suppliers') + .insert({ + user_id: userId, + company_id: companyId, + name: validated.name, + supplier_type: validated.supplier_type, + email: validated.email ?? null, + phone: validated.phone ?? null, + org_number: validated.org_number ?? null, + vat_number: validated.vat_number ?? null, + address_line1: validated.address_line1 ?? null, + address_line2: validated.address_line2 ?? null, + postal_code: validated.postal_code ?? null, + city: validated.city ?? null, + country: validated.country ?? 'SE', + bankgiro: validated.bankgiro ?? null, + plusgiro: validated.plusgiro ?? null, + bank_account: validated.bank_account ?? null, + iban: validated.iban ?? null, + bic: validated.bic ?? null, + default_expense_account: validated.default_expense_account ?? null, + default_payment_terms: validated.default_payment_terms, + default_currency: validated.default_currency ?? 'SEK', + notes: validated.notes ?? null, + }) + .select() + .single() + + if (error) return { error: error.message, status: 500 } + + await eventBus.emit({ type: 'supplier.created', payload: { supplier: data as Supplier, userId, companyId } }) + + return { data: { supplier_id: data.id } } +} + async function commitCreateTransaction( supabase: SupabaseClient, userId: string, @@ -1935,6 +1995,9 @@ export async function commitPendingOperation( case 'create_customer': result = await commitCreateCustomer(supabase, userId, companyId, pendingOp.params) break + case 'create_supplier': + result = await commitCreateSupplier(supabase, userId, companyId, pendingOp.params) + break case 'create_invoice': result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params) break diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index c212bbf8..c79c61a2 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -27,6 +27,12 @@ export const OPERATION_RISK_TIERS: Record = { match_transaction_invoice: 'medium', create_invoice: 'medium', // creates as draft; sending is a separate op create_transaction: 'medium', // ingests an uncategorized row; reversible by delete + // Supplier master data carries payment-routing fields (IBAN, BIC, bankgiro, + // bank_account) that drive outgoing payment files and supplier invoice + // postings. A wrong account or org_number can enable supplier-fraud / BEC + // (silently rerouting payment), so always require explicit human approval + // rather than auto-commit. + create_supplier: 'medium', // Pinning a doc to a tx is reversible while pre-categorization, but the link // becomes part of the verifikation underlag (BFL 5 kap 6 §) once categorize // propagates it. A wrong attachment requires a rättelse, so require human diff --git a/lib/pending-operations/schemas/create-supplier.ts b/lib/pending-operations/schemas/create-supplier.ts new file mode 100644 index 00000000..cb02892c --- /dev/null +++ b/lib/pending-operations/schemas/create-supplier.ts @@ -0,0 +1,154 @@ +/** + * Authoritative server-side validation for the create_supplier staged + * operation. Used by: + * - The MCP tool execute() before staging (extensions/general/mcp-server/server.ts) + * - commitCreateSupplier() before the suppliers INSERT (lib/pending-operations/commit.ts) + * + * Defense in depth: validating at the commit boundary protects the DB even + * if a caller writes directly to pending_operations.params bypassing the + * MCP tool, satisfying ASVS V4.5 / ISO A.8.28 input-validation guidance. + * + * Financial identifiers (IBAN, BIC, bankgiro, plusgiro, org_number, + * vat_number, default_expense_account) are format-validated so adversarial + * or malformed payment-routing data cannot be persisted. Bankgiro additionally + * passes the Luhn check (SE-R-008/009). VAT number format is checked against + * the VIES per-country pattern (SE-R-001, ML 17 kap 24§ p.4). + */ +import { z } from 'zod' +import { validateBankgiroNumber } from '@/lib/bankgiro/luhn' +import { parseVatNumber } from '@/lib/vat/vies-client' + +const IBAN_RE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/ +const BIC_RE = /^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/ +const SE_ORG_NUMBER_RE = /^\d{6}-?\d{4}$|^\d{12}$/ +const COUNTRY_RE = /^[A-Z]{2}$/ +const PLUSGIRO_RE = /^\d{1,7}-?\d{1}$/ +const BAS_EXPENSE_RE = /^[4567]\d{3}$/ +const SUPPLIER_TYPES = ['swedish_business', 'eu_business', 'non_eu_business'] as const + +/** + * Accept string | null | undefined, trim, and normalise empty to undefined. + * Then run the inner zod string validators on the survivor. + */ +function optString(inner: z.ZodTypeAny) { + return z.preprocess( + (v) => { + if (v == null) return undefined + if (typeof v !== 'string') return v + const t = v.trim() + return t === '' ? undefined : t + }, + inner.optional(), + ) +} + +const emailField = optString(z.string().email('Invalid email format').max(255)) +const phoneField = optString(z.string().max(50)) +const orgNumberField = optString( + z + .string() + .max(20) + .refine( + (v) => SE_ORG_NUMBER_RE.test(v.replace(/\s/g, '')), + 'Invalid Swedish org number format (expected XXXXXX-XXXX or 12 digits)', + ), +) +const vatNumberField = optString( + z + .string() + .max(20) + .refine( + (v) => parseVatNumber(v) !== null, + 'Invalid EU VAT number format (must include valid country prefix)', + ), +) +const countryField = optString( + z.string().refine((v) => COUNTRY_RE.test(v.toUpperCase()), 'country must be a 2-letter ISO 3166-1 alpha-2 code'), +) +const bankgiroField = optString( + z.string().max(20).refine( + (v) => validateBankgiroNumber(v), + 'Invalid Bankgiro (must be 7-8 digits with valid Luhn check digit)', + ), +) +const plusgiroField = optString( + z.string().max(20).refine( + (v) => PLUSGIRO_RE.test(v.replace(/\s/g, '')), + 'Invalid Plusgiro (expected 2-8 digits)', + ), +) +const ibanField = optString( + z.string().max(34).refine( + (v) => IBAN_RE.test(v.replace(/\s/g, '').toUpperCase()), + 'Invalid IBAN format', + ), +) +const bicField = optString( + z.string().max(11).refine( + (v) => BIC_RE.test(v.replace(/\s/g, '').toUpperCase()), + 'Invalid BIC/SWIFT format', + ), +) +const expenseAccountField = optString( + z.string().refine( + (v) => BAS_EXPENSE_RE.test(v), + 'default_expense_account must be a 4-digit BAS expense account (class 4, 5, 6, or 7)', + ), +) +// Accept either a number or a numeric string. Critically, an explicit 0 is +// preserved (some suppliers are due-on-receipt). null/undefined falls through +// to the 30-day default via .default(). +const paymentTermsField = z + .preprocess( + (v) => { + if (v == null || v === '') return undefined + if (typeof v === 'number') return v + if (typeof v === 'string') { + const n = Number(v) + return Number.isNaN(n) ? v : n + } + return v + }, + z.number().int('default_payment_terms must be an integer').min(0).max(365).optional(), + ) + .default(30) + +export const CreateSupplierParamsSchema = z + .object({ + name: z + .preprocess( + (v) => (typeof v === 'string' ? v.trim() : v), + z.string().min(1, 'Supplier name is required').max(255), + ), + supplier_type: z.enum(SUPPLIER_TYPES).default('swedish_business'), + email: emailField, + phone: phoneField, + org_number: orgNumberField, + vat_number: vatNumberField, + address_line1: optString(z.string().max(255)), + address_line2: optString(z.string().max(255)), + postal_code: optString(z.string().max(20)), + city: optString(z.string().max(100)), + country: countryField, + bankgiro: bankgiroField, + plusgiro: plusgiroField, + bank_account: optString(z.string().max(50)), + iban: ibanField, + bic: bicField, + default_expense_account: expenseAccountField, + default_payment_terms: paymentTermsField, + default_currency: optString(z.string().length(3, 'currency must be a 3-letter ISO code')), + notes: optString(z.string().max(2000)), + }) + .strict() + .superRefine((val, ctx) => { + if (val.supplier_type === 'eu_business' && !val.vat_number) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['vat_number'], + message: 'EU business suppliers must have an EU VAT number (ML 17 kap 24§)', + }) + } + }) + +export type CreateSupplierParams = z.infer diff --git a/lib/providers/visma/oauth.ts b/lib/providers/visma/oauth.ts index 8dc9f253..90110ce0 100644 --- a/lib/providers/visma/oauth.ts +++ b/lib/providers/visma/oauth.ts @@ -7,24 +7,29 @@ import { } from '@/lib/http/fetch-with-timeout'; const DEFAULT_SCOPES = [ - 'offline_access', 'ea:api', + 'offline_access', 'ea:sales', 'ea:accounting', 'ea:purchase', - 'vls:api', ]; const EACCOUNTING_ACR_VALUE = 'service:44643EB1-3F76-4C1C-A672-402AE8085934'; +const ALLOWED_PROMPT_VALUES = new Set(['none', 'login', 'consent', 'select_account']); + export function buildVismaAuthUrl( config: OAuthConfig, - options?: { scopes?: string[]; state?: string; acrValues?: string }, + options?: { scopes?: string[]; state?: string; acrValues?: string; prompt?: string }, ): string { + const promptCandidate = options?.prompt ?? 'select_account'; + const prompt = ALLOWED_PROMPT_VALUES.has(promptCandidate) ? promptCandidate : 'select_account'; + const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: config.redirectUri, response_type: 'code', + prompt, acr_values: options?.acrValues ?? EACCOUNTING_ACR_VALUE, }); diff --git a/supabase/migrations/20260520120000_pending_operations_add_create_supplier.sql b/supabase/migrations/20260520120000_pending_operations_add_create_supplier.sql new file mode 100644 index 00000000..a9d9d298 --- /dev/null +++ b/supabase/migrations/20260520120000_pending_operations_add_create_supplier.sql @@ -0,0 +1,54 @@ +-- Expand pending_operations.operation_type to include create_supplier. +-- +-- The MCP server can already stage customers via gnubok_create_customer, but +-- supplier creation (gnubok_create_supplier) requires its own staged op type +-- so the dispatcher in lib/pending-operations/commit.ts can route it to the +-- suppliers-table insert path. Without this CHECK update the INSERT into +-- pending_operations fails with a check_violation. +-- +-- Same low-risk tier as create_customer (pure data, no booking impact). + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + -- Phase 0: original 7 op types + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + -- Stream 1 Phase 1: bookkeeping period operations + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + -- Stream 1 Phase 1: SIE import (export is read-only) + 'import_sie', + -- Stream 1 Phase 1: voucher gap explanations + 'explain_voucher_gap', + -- Stream 1 Phase 1: transaction reversal + 'uncategorize_transaction', + -- Stream 1 Phase 1: supplier invoice lifecycle + 'approve_supplier_invoice', + 'credit_supplier_invoice', + -- Stream 1 Phase 1: invoice operations beyond simple create/send + 'credit_invoice', + 'convert_invoice', + -- Phase 3: manual transaction ingestion + document attachment + 'create_transaction', + 'attach_document_to_transaction', + -- Phase 4: arbitrary-line bookkeeping primitives + 'create_voucher', + 'correct_entry', + -- Phase 5 (this migration): supplier CRUD + 'create_supplier' + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 9e33b440..fa87b4d9 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1414,6 +1414,7 @@ export interface CreateFiscalPeriodInput { export type PendingOperationType = | 'categorize_transaction' | 'create_customer' + | 'create_supplier' | 'create_invoice' | 'mark_invoice_paid' | 'send_invoice'