feat: add currency for articles (#834)
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
This commit is contained in:
committed by
GitHub
parent
2a1ec5ec2f
commit
edef48471c
@@ -70,8 +70,8 @@ export const PATCH = withRouteContext(
|
||||
const updateData: Record<string, unknown> = {}
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user