feat(mcp): currency param on create_article and update_article (#1184)
Fixes #1168. articles.currency exists in the DB and REST API, but the staged-operation schemas and the two MCP tools had no currency param, so agent-created articles were always SEK and an agent asked to create an EUR-priced article could not. - CreateArticleParamsSchema/UpdateArticleParamsSchema accept an optional ISO 4217 code (normalized to upper case; empty/null = unset). The currencies-table FK stays the allow-list: a 23503 on the currency FK maps to a clear 400 instead of a raw 500. - commitCreateArticle inserts currency ?? 'SEK'; the sparse update executor passes it through only when staged. - gnubok_create_article / gnubok_update_article expose the param. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown> = { 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]
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user