feat: add currency for articles (#834)

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
This commit is contained in:
Alexander Reinthal
2026-07-16 16:00:05 +02:00
committed by GitHub
co-authored by Jakob Wennberg
parent 2a1ec5ec2f
commit edef48471c
11 changed files with 193 additions and 6 deletions
+66 -1
View File
@@ -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<CurrencyOption[]>([])
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({
<p className="text-xs text-muted-foreground">{t('cost_price_hint')}</p>
</div>
{/* Currency */}
<div className="space-y-2">
<Label>{t('currency_label')}</Label>
<Controller
name="currency"
control={control}
render={({ field }) => {
// 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 (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
)
}}
/>
<p className="text-xs text-muted-foreground">{t('currency_hint')}</p>
</div>
{/* EAN */}
<div className="space-y-2">
<Label htmlFor="ean">{t('ean_label')}</Label>
+25 -3
View File
@@ -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<FormData>({
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