diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 64897d91..6f73d112 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -3911,6 +3911,7 @@ export const tools: McpTool[] = [ type: { type: 'string', enum: ['vara', 'tjanst'], description: 'Good (vara) or service (tjanst). Default tjanst.' }, unit: { type: 'string', description: 'Unit, e.g. st, tim, kg. Default st.' }, price_excl_vat: { type: 'number', description: 'Unit price EXCLUDING VAT.' }, + currency: { type: 'string', description: 'Price currency as ISO 4217 code (e.g. EUR). Default SEK. Pre-fills the invoice currency when the article is added.' }, vat_rate: { type: 'number', enum: [0, 6, 12, 25], description: 'VAT rate percent. Default 25.' }, revenue_account: { type: 'string', description: 'Optional BAS class-3 revenue account (e.g. 3041). Omit to derive from VAT.' }, cost_price: { type: 'number', description: 'Optional cost price (margin only; never booked).' }, @@ -3942,6 +3943,7 @@ export const tools: McpTool[] = [ type: (args.type as string) || 'tjanst', unit: (args.unit as string) || undefined, price_excl_vat: args.price_excl_vat, + currency: (args.currency as string) || undefined, vat_rate: typeof args.vat_rate === 'number' ? args.vat_rate : 25, revenue_account: (args.revenue_account as string) || null, cost_price: typeof args.cost_price === 'number' ? args.cost_price : null, @@ -3983,6 +3985,7 @@ export const tools: McpTool[] = [ type: { type: 'string', enum: ['vara', 'tjanst'] }, unit: { type: 'string' }, price_excl_vat: { type: 'number' }, + currency: { type: 'string', description: 'Price currency as ISO 4217 code (e.g. EUR), or omit to leave unchanged.' }, vat_rate: { type: 'number', enum: [0, 6, 12, 25] }, revenue_account: { type: 'string', description: 'BAS class-3 revenue account, or omit to leave unchanged.' }, cost_price: { type: 'number' }, @@ -4008,7 +4011,7 @@ export const tools: McpTool[] = [ const params: Record = { article_id: articleId } for (const key of [ - 'name', 'type', 'unit', 'price_excl_vat', 'vat_rate', 'revenue_account', + 'name', 'type', 'unit', 'price_excl_vat', 'currency', 'vat_rate', 'revenue_account', 'cost_price', 'ean', 'housework_type', 'name_en', 'notes', 'active', ]) { if (args[key] !== undefined) params[key] = args[key] diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 0aa29674..2f7fd938 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -488,6 +488,7 @@ async function commitCreateArticle( type: validated.type, unit: validated.unit ?? 'st', price_excl_vat: validated.price_excl_vat, + currency: validated.currency ?? 'SEK', vat_rate: validated.vat_rate, revenue_account: validated.revenue_account ?? null, cost_price: validated.cost_price ?? null, @@ -499,7 +500,13 @@ async function commitCreateArticle( .select() .single() - if (error) return { error: error.message, status: 500 } + if (error) { + // FK to public.currencies: the reference table is the allow-list. + if (error.code === '23503' && error.message.includes('currency')) { + return { error: `Currency ${validated.currency} is not supported`, status: 400 } + } + return { error: error.message, status: 500 } + } if (!data.article_number) { try { @@ -552,6 +559,10 @@ async function commitUpdateArticle( if (error) { if (error.code === 'PGRST116') return { error: 'Article not found', status: 404 } + // FK to public.currencies: the reference table is the allow-list. + if (error.code === '23503' && error.message.includes('currency')) { + return { error: `Currency ${validated.currency} is not supported`, status: 400 } + } return { error: error.message, status: 500 } } diff --git a/lib/pending-operations/schemas/__tests__/article.test.ts b/lib/pending-operations/schemas/__tests__/article.test.ts new file mode 100644 index 00000000..6ab1fe44 --- /dev/null +++ b/lib/pending-operations/schemas/__tests__/article.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '../article' + +const base = { name: 'EU-konsulting', price_excl_vat: 950 } + +describe('CreateArticleParamsSchema currency', () => { + it('accepts and normalizes an ISO code to upper case', () => { + const parsed = CreateArticleParamsSchema.parse({ ...base, currency: 'eur' }) + expect(parsed.currency).toBe('EUR') + }) + + it('treats empty string and null as unset (commit defaults to SEK)', () => { + expect(CreateArticleParamsSchema.parse({ ...base, currency: '' }).currency).toBeUndefined() + expect(CreateArticleParamsSchema.parse({ ...base, currency: null }).currency).toBeUndefined() + expect(CreateArticleParamsSchema.parse(base).currency).toBeUndefined() + }) + + it('rejects non-ISO shapes', () => { + expect(() => CreateArticleParamsSchema.parse({ ...base, currency: 'EURO' })).toThrow() + expect(() => CreateArticleParamsSchema.parse({ ...base, currency: 'E1' })).toThrow() + }) +}) + +describe('UpdateArticleParamsSchema currency', () => { + const id = { article_id: '3a9ac4d2-163a-4d43-8fa3-1b32827505fa' } + + it('accepts a currency-only update', () => { + const parsed = UpdateArticleParamsSchema.parse({ ...id, currency: 'usd' }) + expect(parsed.currency).toBe('USD') + }) + + it('leaves currency undefined when omitted (sparse update must not touch it)', () => { + const parsed = UpdateArticleParamsSchema.parse({ ...id, name: 'Nytt namn' }) + expect(parsed.currency).toBeUndefined() + }) +}) diff --git a/lib/pending-operations/schemas/article.ts b/lib/pending-operations/schemas/article.ts index 5a14a9cc..c32f3a1a 100644 --- a/lib/pending-operations/schemas/article.ts +++ b/lib/pending-operations/schemas/article.ts @@ -21,11 +21,20 @@ const trimmedName = z.preprocess( z.string().min(1, 'Article name is required').max(200), ) +// ISO 4217 shape, normalized to upper case; the currencies-table FK on +// articles.currency is the authoritative allow-list (unknown codes fail at +// commit with a clear message). Empty string / null → undefined. +const currencyCode = z.preprocess( + (v) => (v == null || v === '' ? undefined : typeof v === 'string' ? v.trim().toUpperCase() : v), + z.string().regex(/^[A-Z]{3}$/, 'Currency must be a 3-letter ISO 4217 code (e.g. EUR)').optional(), +) + export const CreateArticleParamsSchema = z.object({ name: trimmedName, type: z.enum(['vara', 'tjanst']).default('tjanst'), unit: optString(32), price_excl_vat: z.number().nonnegative(), + currency: currencyCode, vat_rate: vatRatePercent.default(25), revenue_account: invoicePostingAccount.nullable().optional(), cost_price: z.number().nonnegative().nullable().optional(), @@ -42,6 +51,7 @@ export const UpdateArticleParamsSchema = z.object({ type: z.enum(['vara', 'tjanst']).optional(), unit: optString(32), price_excl_vat: z.number().nonnegative().optional(), + currency: currencyCode, vat_rate: vatRatePercent.optional(), revenue_account: invoicePostingAccount.nullable().optional(), cost_price: z.number().nonnegative().nullable().optional(),