diff --git a/app/api/articles/[id]/route.ts b/app/api/articles/[id]/route.ts index 4d4397eb..98a87095 100644 --- a/app/api/articles/[id]/route.ts +++ b/app/api/articles/[id]/route.ts @@ -70,8 +70,8 @@ export const PATCH = withRouteContext( const updateData: Record = {} for (const key of [ 'name', 'name_en', 'type', 'unit', 'price_excl_vat', 'vat_rate', - 'revenue_account', 'cost_price', 'ean', 'housework_type', 'notes', - 'article_number', 'active', + 'currency', 'revenue_account', 'cost_price', 'ean', 'housework_type', + 'notes', 'article_number', 'active', ] as const) { if (body[key] !== undefined) updateData[key] = body[key] } diff --git a/app/api/articles/route.ts b/app/api/articles/route.ts index 951086aa..0c423358 100644 --- a/app/api/articles/route.ts +++ b/app/api/articles/route.ts @@ -78,6 +78,7 @@ export const POST = withRouteContext( unit: body.unit ?? 'st', price_excl_vat: body.price_excl_vat, vat_rate: body.vat_rate ?? 25, + currency: body.currency ?? 'SEK', revenue_account: body.revenue_account ?? null, cost_price: body.cost_price ?? null, ean: body.ean ?? null, diff --git a/components/articles/ArticleForm.tsx b/components/articles/ArticleForm.tsx index c2057a28..9f88af1d 100644 --- a/components/articles/ArticleForm.tsx +++ b/components/articles/ArticleForm.tsx @@ -19,6 +19,13 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import type { BASAccount, CreateArticleInput } from '@/types' +// A row from the currencies reference table (lib migration +// 20260630110000_currencies_reference_table.sql). +interface CurrencyOption { + code: string + name: string +} + // Unit list mirrors the invoice line editor (app/(dashboard)/invoices/new/page.tsx). const UNITS = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] as const @@ -52,6 +59,10 @@ export default function ArticleForm({ // Momsregistrerad? A non-VAT-registered company never charges moms, so the // VAT field is hidden and the rate forced to 0: mirrors the invoice editor. const [vatRegistered, setVatRegistered] = useState(true) + // Supported currencies, fetched from the currencies reference table rather + // than hard-coded. Falls back to the article's own currency (or SEK) if the + // fetch fails so the Select is never empty. + const [currencies, setCurrencies] = useState([]) async function fetchRevenueAccounts() { try { @@ -67,6 +78,24 @@ export default function ArticleForm({ fetchRevenueAccounts() }, []) + // Currency options come from the currencies reference table: one source of + // truth, no hard-coded list. + useEffect(() => { + let cancelled = false + supabase + .from('currencies') + .select('code, name') + .eq('active', true) + .order('sort_order', { ascending: true }) + .then(({ data }) => { + if (!cancelled && data) setCurrencies(data as CurrencyOption[]) + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + useEffect(() => { if (!company?.id) return let cancelled = false @@ -89,7 +118,8 @@ export default function ArticleForm({ // edit never hides a value the user previously set. const [advancedOpen, setAdvancedOpen] = useState( Boolean( - initialData?.revenue_account || + (initialData?.currency && initialData.currency !== 'SEK') || + initialData?.revenue_account || initialData?.cost_price != null || initialData?.ean || initialData?.housework_type || @@ -107,6 +137,9 @@ export default function ArticleForm({ unit: z.string().min(1), price_excl_vat: z.number({ message: t('price_required') }).nonnegative(t('price_required')), vat_rate: z.union([z.literal(25), z.literal(12), z.literal(6), z.literal(0)]), + // ISO 4217 alpha-3; the authoritative allow-list is the currencies + // table (the DB FK rejects unknown codes). + currency: z.string().regex(/^[A-Z]{3}$/), revenue_account: z.string().optional(), cost_price: z.number().nonnegative().optional(), ean: z.string().optional(), @@ -134,6 +167,7 @@ export default function ArticleForm({ unit: initialData?.unit || 'st', price_excl_vat: initialData?.price_excl_vat ?? 0, vat_rate: (initialData?.vat_rate as FormData['vat_rate']) ?? 25, + currency: initialData?.currency ?? 'SEK', revenue_account: initialData?.revenue_account || '', cost_price: initialData?.cost_price ?? undefined, ean: initialData?.ean || '', @@ -152,6 +186,7 @@ export default function ArticleForm({ unit: data.unit, price_excl_vat: data.price_excl_vat, vat_rate: vatRegistered ? data.vat_rate : 0, + currency: data.currency, revenue_account: data.revenue_account || null, cost_price: data.cost_price ?? null, ean: data.ean || null, @@ -317,6 +352,36 @@ export default function ArticleForm({

{t('cost_price_hint')}

+ {/* Currency */} +
+ + { + // Always keep the current value selectable, even before the + // fetch resolves or if it's since been deactivated. + const codes = currencies.map((c) => c.code) + const options = codes.includes(field.value) + ? codes + : [field.value, ...codes] + return ( + + ) + }} + /> +

{t('currency_hint')}

+
+ {/* EAN */}
diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 93fa0fdf..84c0103a 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -84,7 +84,7 @@ export type InvoiceEditorProps = ( // Subset of Article fields the line picker needs to pre-fill a row. type ArticleOption = Pick< Article, - 'id' | 'article_number' | 'name' | 'unit' | 'price_excl_vat' | 'vat_rate' | 'revenue_account' + 'id' | 'article_number' | 'name' | 'unit' | 'price_excl_vat' | 'vat_rate' | 'revenue_account' | 'currency' > function RequiredMark() { @@ -321,7 +321,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat watch, setValue, setError, - formState: { errors, isDirty }, + getValues, + formState: { errors, isDirty, dirtyFields }, } = useForm({ resolver: zodResolver(schema), // Edit mode pre-fills from the existing draft (header + every line incl. @@ -475,7 +476,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat if (!company?.id) return const { data } = await supabase .from('articles') - .select('id, article_number, name, unit, price_excl_vat, vat_rate, revenue_account') + .select('id, article_number, name, unit, price_excl_vat, vat_rate, revenue_account, currency') .eq('company_id', company.id) .eq('active', true) .order('name') @@ -517,6 +518,27 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat // The account override rides along regardless of rate; the engine ignores it // for reverse-charge/export and validates it against the chart of accounts. setValue(`items.${index}.revenue_account`, a.revenue_account ?? null, { shouldDirty: true }) + // Pre-fill the invoice's (single) currency from the article ONLY on the + // first priced line, and only while the user hasn't chosen a currency + // themselves. Never flip an in-progress invoice's currency on a later pick: + // an invoice carries one currency for all its lines, so overwriting it would + // relabel existing line amounts (or the user's explicit choice) as another + // currency with no FX conversion, producing a legally wrong faktura and + // wrong VAT (ML 17 kap). The article's currency comes from the currencies + // reference table. + const currencyUserSet = Boolean(dirtyFields.currency) + const invoiceHasOtherContent = (watchItems ?? []).some( + (it, i) => i !== index && (Boolean(it?.article_id) || Number(it?.unit_price) > 0) + ) + if ( + a.currency && + currencies.includes(a.currency as Currency) && + a.currency !== getValues('currency') && + !currencyUserSet && + !invoiceHasOtherContent + ) { + setValue('currency', a.currency as Currency, { shouldDirty: true }) + } } // "Spara som artikel": persist the current free-text line into the register and diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index b57c6fb9..ba7ebad1 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -529,6 +529,11 @@ export const CreateArticleSchema = z.object({ unit: z.string().min(1).max(32).optional(), price_excl_vat: nonNegativeAmount, vat_rate: vatRatePercent.optional(), + // Default price currency; omitted = SEK. Pre-fills a new invoice's currency. + // Constrained to the same CurrencySchema enum invoices use, which mirrors the + // seeded currencies reference table: an unknown code is a clean 400 here + // instead of a raw FK violation (23503) surfacing at insert time. + currency: CurrencySchema.optional(), // Optional BAS class-3 revenue-account override. Null/omitted = derive from // the invoice's VAT treatment (current behaviour). revenue_account: revenueAccount.nullable().optional(), diff --git a/messages/en.json b/messages/en.json index 41806c73..2d4566fc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4363,6 +4363,8 @@ "revenue_account_hint": "Leave empty to derive the account automatically from the VAT rate.", "cost_price_label": "Cost price", "cost_price_hint": "Margin display only, never posted.", + "currency_label": "Currency", + "currency_hint": "Pre-fills a new invoice's currency when the article is added.", "ean_label": "EAN/barcode", "ean_placeholder": "e.g. 7350000000000", "housework_label": "ROT/RUT", diff --git a/messages/sv.json b/messages/sv.json index 7bb778c2..26400e04 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -4363,6 +4363,8 @@ "revenue_account_hint": "Lämna tomt för att härleda kontot automatiskt utifrån momsen.", "cost_price_label": "Inköpspris", "cost_price_hint": "Endast för marginalberäkning, bokförs aldrig.", + "currency_label": "Valuta", + "currency_hint": "Förifyller en ny fakturas valuta när artikeln läggs till.", "ean_label": "EAN/streckkod", "ean_placeholder": "t.ex. 7350000000000", "housework_label": "ROT/RUT", diff --git a/supabase/migrations/20260712140000_currencies_reference_table.sql b/supabase/migrations/20260712140000_currencies_reference_table.sql new file mode 100644 index 00000000..790a29a7 --- /dev/null +++ b/supabase/migrations/20260712140000_currencies_reference_table.sql @@ -0,0 +1,32 @@ +-- Currencies reference table: single source of truth for the supported +-- currency code list. Previously the list was hard-coded in many places +-- (CurrencySchema, the journal-entry/invoice editors, CHECK constraints). This +-- table lets the UI fetch the options and lets columns FK against it instead of +-- repeating a literal list. Global reference data, NOT company-scoped. +CREATE TABLE IF NOT EXISTS public.currencies ( + code text PRIMARY KEY CHECK (code ~ '^[A-Z]{3}$'), -- ISO 4217 alpha-3 + name text NOT NULL, + sort_order integer NOT NULL DEFAULT 100, + active boolean NOT NULL DEFAULT true +); + +-- Seed the codes the app already supported. Add rows here (no code change) to +-- offer more currencies. +INSERT INTO public.currencies (code, name, sort_order) VALUES + ('SEK', 'Swedish krona', 10), + ('EUR', 'Euro', 20), + ('USD', 'US dollar', 30), + ('GBP', 'Pound sterling', 40), + ('NOK', 'Norwegian krone', 50), + ('DKK', 'Danish krone', 60) +ON CONFLICT (code) DO NOTHING; + +-- Reference data: every authenticated user may read it; nobody writes it from +-- the app (managed via migrations). +ALTER TABLE public.currencies ENABLE ROW LEVEL SECURITY; +CREATE POLICY "authenticated read currencies" + ON public.currencies FOR SELECT + TO authenticated + USING (true); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260712140100_articles_add_currency.sql b/supabase/migrations/20260712140100_articles_add_currency.sql new file mode 100644 index 00000000..bfa3c4bd --- /dev/null +++ b/supabase/migrations/20260712140100_articles_add_currency.sql @@ -0,0 +1,14 @@ +-- Articles: optional default currency for the article's price. +-- +-- Pre-fills the invoice currency when the article is added to a line (the +-- invoice still carries a single currency; the article supplies the default). +-- 'SEK' = the existing behaviour. Master data only, never posted; the frozen +-- invoice line keeps its own currency, so editing this never moves a voucher. +-- +-- Validity is enforced by a FK to public.currencies, NOT a literal CHECK list, +-- so the supported set lives in one place (the currencies table). +ALTER TABLE public.articles + ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'SEK' + REFERENCES public.currencies(code); + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/articles.pg.test.ts b/tests/pg/articles.pg.test.ts index 7fc0d294..a9351926 100644 --- a/tests/pg/articles.pg.test.ts +++ b/tests/pg/articles.pg.test.ts @@ -114,6 +114,46 @@ describe('articles constraints + RLS', () => { }) }) +describe('articles.currency', () => { + it('defaults to SEK and accepts a code from the currencies table', async () => { + const { userId, companyId } = await seedCompany() + const articleId = await insertArticle(companyId, userId) + + const def = await getPool().query<{ currency: string }>( + `SELECT currency FROM public.articles WHERE id = $1`, + [articleId], + ) + expect(def.rows[0].currency).toBe('SEK') + + // EUR is seeded in the currencies table, so the FK accepts it. + await expect( + getPool().query(`UPDATE public.articles SET currency = 'EUR' WHERE id = $1`, [articleId]), + ).resolves.toBeDefined() + }) + + it('rejects a currency code missing from the currencies table (FK)', async () => { + const { userId, companyId } = await seedCompany() + const id = randomUUID() + await expect( + getPool().query( + `INSERT INTO public.articles (id, company_id, user_id, name, currency) + VALUES ($1, $2, $3, 'X', 'ZZZ')`, + [id, companyId, userId], + ), + ).rejects.toThrow(/foreign key|currencies/i) + }) +}) + +describe('currencies reference table', () => { + it('is readable by any authenticated user via RLS', async () => { + const reader = await insertAuthUser() + const seen = await withUserContext(reader, (client) => + client.query<{ code: string }>(`SELECT code FROM public.currencies WHERE code = 'SEK'`), + ) + expect(seen.rows).toHaveLength(1) + }) +}) + describe('articles triggers', () => { it('bumps updated_at on update', async () => { const { userId, companyId } = await seedCompany() diff --git a/types/index.ts b/types/index.ts index b6393ee2..46d8da9b 100644 --- a/types/index.ts +++ b/types/index.ts @@ -652,6 +652,9 @@ export interface Article { price_excl_vat: number /** Default line VAT rate as an integer percent: 25 | 12 | 6 | 0. */ vat_rate: number + /** Default price currency (ISO 4217 code from the currencies table); + * pre-fills the invoice currency when added. */ + currency: string /** Optional BAS class-3 revenue account override. null = derive from VAT treatment. */ revenue_account: string | null /** Margin/display only: never posted to the ledger. */ @@ -673,6 +676,7 @@ export interface CreateArticleInput { unit?: string price_excl_vat: number vat_rate?: number + currency?: string revenue_account?: string | null cost_price?: number | null ean?: string | null