Fix/dependabot cus feedback (#946)

* feat(bookkeeping): per-account default VAT, oresavrundning momsfri

Add a per-account "Standard moms" setting to the chart of accounts and use
it to auto-fill the moms on a leverantorsfaktura-rad when that konto is
picked. Oresavrundning (3740) ships as "Ingen moms", so a rounding line no
longer inherits the 25 % rad-default and skews the moms.

- chart_of_accounts.default_vat_rate (0/0.06/0.12/0.25, CHECK-constrained)
- BEFORE INSERT trigger ships 3740 momsfri on every insert path; backfills
  existing 3740 rows
- kontoplan editor: dead free-text momskod replaced with a Standard moms select
- supplier-invoice rad auto-fills the rate from the konto default

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(supplier-invoices): configurable start number for the ankomstnummer series

Add a company_settings.next_arrival_number start floor so a company can continue its leverantorsfaktura numbering from a previous system (e.g. Fortnox) instead of restarting the ankomstnummer at 1. get_next_arrival_number now floors the series via GREATEST(MAX(arrival_number)+1, next_arrival_number), so the floor can never move the series backwards or collide with the (company_id, arrival_number) unique index.

The RPC is hardened while rewritten: SET search_path to empty, schema-qualified refs, and an auth.uid() membership check matching generate_invoice_number.

Includes the settings UI field, sv/en strings, migration, and pg-real coverage. The CompanySettings type and Zod schema field for this feature landed earlier in 1bf3b641 (swept into the per-account VAT commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dependabot): reduce open pull requests limit and group updates for better management

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-09 12:19:57 +02:00
committed by GitHub
parent b1f85bc33e
commit bacc5914af
19 changed files with 570 additions and 26 deletions
+29 -9
View File
@@ -1,4 +1,13 @@
version: 2
# Deliberately throttled to avoid a PR flood. Two knobs do the work:
# - open-pull-requests-limit: 1 -> at most ONE open PR per ecosystem at a
# time. Dependabot will not open next week's PR until the current one is
# merged or closed, so PRs can never pile up.
# - groups (patterns: "*") -> every available bump (major/minor/patch)
# is batched into that single PR instead of one PR per package.
# Combined with the weekly schedule this means: normally one npm PR a week (or
# none), and only in a rare week where Docker/Actions also move do you see more
# than one PR at all.
updates:
# Base images in the root Dockerfile (node:22-alpine).
- package-ecosystem: docker
@@ -6,7 +15,11 @@ updates:
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
open-pull-requests-limit: 1
groups:
docker:
patterns:
- "*"
labels:
- dependencies
- docker
@@ -17,7 +30,11 @@ updates:
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
open-pull-requests-limit: 1
groups:
docker-cron:
patterns:
- "*"
labels:
- dependencies
- docker
@@ -28,7 +45,11 @@ updates:
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
open-pull-requests-limit: 1
groups:
github-actions:
patterns:
- "*"
labels:
- dependencies
- ci
@@ -39,16 +60,15 @@ updates:
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 10
open-pull-requests-limit: 1
labels:
- dependencies
- npm
groups:
# Batch low-risk minor/patch bumps so the reviewer queue stays small.
minor-and-patch:
update-types:
- minor
- patch
# Batch ALL bumps (major/minor/patch) into a single weekly PR.
npm:
patterns:
- "*"
ignore:
# @anthropic-ai/bedrock-sdk is PINNED to an exact version in package.json.
# 0.32.0 arrived inside a grouped minor-and-patch bump (#884) and broke
@@ -131,6 +131,29 @@ describe('POST /api/bookkeeping/accounts', () => {
expect(status).toBe(409)
expect(body.error).toContain('5010')
})
it('forwards default_vat_rate into the insert', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: { account_number: '3740', default_vat_rate: 0 } },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts', {
method: 'POST',
body: {
account_number: '3740',
account_name: 'Öres- och kronutjämning',
account_type: 'revenue',
normal_balance: 'debit',
default_vat_rate: 0,
},
})
const { status } = await parseJsonResponse(await createPOST(req, routeParams))
expect(status).toBe(200)
const insertArg = calls.find((c) => c.method === 'insert')?.args[0] as {
default_vat_rate?: number | null
}
expect(insertArg?.default_vat_rate).toBe(0)
})
})
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
@@ -218,6 +241,25 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => {
expect(status).toBe(200)
expect(body.data.account_name).toBe('Nytt namn')
})
it('forwards default_vat_rate into the update', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: { account_number: '3740', default_vat_rate: 0 } },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/3740', {
method: 'PUT',
body: { default_vat_rate: 0 },
})
const { status } = await parseJsonResponse(
await PUT(req, { params: Promise.resolve({ number: '3740' }) })
)
expect(status).toBe(200)
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as {
default_vat_rate?: number | null
}
expect(updateArg?.default_vat_rate).toBe(0)
})
})
describe('POST /api/bookkeeping/accounts/activate', () => {
+1
View File
@@ -79,6 +79,7 @@ export const POST = withRouteContext(
is_system_account: false,
description: body.description || null,
default_vat_code: body.default_vat_code || null,
default_vat_rate: body.default_vat_rate ?? null,
sru_code: body.sru_code || null,
sort_order: parseInt(body.account_number),
})
+18 -9
View File
@@ -37,7 +37,9 @@ export function AddAccountDialog({
const [accountNumber, setAccountNumber] = useState('')
const [accountName, setAccountName] = useState('')
const [description, setDescription] = useState('')
const [defaultVatCode, setDefaultVatCode] = useState('')
// "Standard moms": the moms-sats a booking line defaults to when this konto is
// picked. 'none' = no default. SelectItem values are stringified decimals.
const [defaultVatRate, setDefaultVatRate] = useState('none')
const [sruCode, setSruCode] = useState('')
const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit')
const [isSaving, setIsSaving] = useState(false)
@@ -84,7 +86,7 @@ export function AddAccountDialog({
account_type: derived?.account_type || 'expense',
normal_balance: normalBalance,
description: description || null,
default_vat_code: defaultVatCode || null,
default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate),
sru_code: sruCode || null,
}),
})
@@ -100,7 +102,7 @@ export function AddAccountDialog({
setAccountNumber('')
setAccountName('')
setDescription('')
setDefaultVatCode('')
setDefaultVatRate('none')
setSruCode('')
onCreated(createdAccount)
onOpenChange(false)
@@ -197,12 +199,19 @@ export function AddAccountDialog({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Momskod <span className="text-muted-foreground">(valfritt)</span></Label>
<Input
value={defaultVatCode}
onChange={(e) => setDefaultVatCode(e.target.value)}
placeholder="T.ex. MP1"
/>
<Label>Standard moms <span className="text-muted-foreground">(valfritt)</span></Label>
<Select value={defaultVatRate} onValueChange={setDefaultVatRate}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen standard</SelectItem>
<SelectItem value="0">Ingen moms</SelectItem>
<SelectItem value="0.25">25 %</SelectItem>
<SelectItem value="0.12">12 %</SelectItem>
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>SRU-kod <span className="text-muted-foreground">(valfritt)</span></Label>
+20 -8
View File
@@ -59,7 +59,12 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
const { toast } = useToast()
const [accountName, setAccountName] = useState(account.account_name)
const [description, setDescription] = useState(account.description || '')
const [defaultVatCode, setDefaultVatCode] = useState(account.default_vat_code || '')
// "Standard moms": the moms-sats a booking line defaults to when this konto is
// picked (currently the leverantörsfaktura-rad). 'none' = no default. Stored
// as a decimal fraction; SelectItem values are the stringified decimals.
const [defaultVatRate, setDefaultVatRate] = useState(
account.default_vat_rate != null ? String(account.default_vat_rate) : 'none',
)
const [sruCode, setSruCode] = useState(account.sru_code || '')
const [isActive, setIsActive] = useState(account.is_active)
const [isSaving, setIsSaving] = useState(false)
@@ -250,7 +255,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
body: JSON.stringify({
account_name: accountName,
description: description || null,
default_vat_code: defaultVatCode || null,
default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate),
sru_code: sruCode || null,
is_active: isActive,
}),
@@ -310,12 +315,19 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Standard momskod</Label>
<Input
value={defaultVatCode}
onChange={(e) => setDefaultVatCode(e.target.value)}
placeholder="T.ex. MP1"
/>
<Label>Standard moms</Label>
<Select value={defaultVatRate} onValueChange={setDefaultVatRate}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen standard</SelectItem>
<SelectItem value="0">Ingen moms</SelectItem>
<SelectItem value="0.25">25 %</SelectItem>
<SelectItem value="0.12">12 %</SelectItem>
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>SRU-kod</Label>
@@ -50,6 +50,22 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="next_arrival_number">{t('arrival_start_label')}</Label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input
id="next_arrival_number"
name="next_arrival_number"
type="number"
min="1"
defaultValue={settings.next_arrival_number || 1}
/>
</div>
<p className="text-xs text-muted-foreground">
{t('arrival_start_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_notes">{t('default_notes_label')}</Label>
<Textarea
@@ -45,6 +45,7 @@ export function InvoicingSettingsContent() {
swish: normaliseSwish(formData.get('swish') as string) || null,
invoice_prefix: (formData.get('invoice_prefix') as string) || null,
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
next_arrival_number: parseInt(formData.get('next_arrival_number') as string) || 1,
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
default_our_reference: (formData.get('default_our_reference') as string) || null,
@@ -697,6 +697,18 @@ export default function NewSupplierInvoiceForm({
const desc = getAccountDescription(accountNumber)
if (desc) setValue(`items.${index}.description`, desc.name)
}
// Auto-fill the rad's moms from the konto's configured default (e.g.
// öresavrundning 3740 = ingen moms), so a rounding line stops inheriting
// the 25 % rad-default. Only when the konto carries an explicit default;
// otherwise the user's current rate stands. Reverse charge uses its own
// rate field, so leave that flow untouched. PostgREST serialises numeric
// columns as strings, so coerce: strict === comparisons on vat_rate
// (inferVatTreatment) expect a number.
const acct = accounts.find((a) => a.account_number === accountNumber)
const defaultRate = acct?.default_vat_rate == null ? null : Number(acct.default_vat_rate)
if (!watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) {
setValue(`items.${index}.vat_rate`, defaultRate, { shouldDirty: true })
}
}
// Periodisering per rad: kräver faktureringsmetoden; eget utlägg bokar
+33
View File
@@ -1175,6 +1175,20 @@ describe('UpdateSettingsSchema', () => {
expect(result.success).toBe(true)
})
it('accepts a positive next_arrival_number (supplier-invoice start floor)', () => {
const result = UpdateSettingsSchema.safeParse({ next_arrival_number: 248 })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.next_arrival_number).toBe(248)
}
})
it('rejects a non-positive next_arrival_number', () => {
expect(UpdateSettingsSchema.safeParse({ next_arrival_number: 0 }).success).toBe(false)
expect(UpdateSettingsSchema.safeParse({ next_arrival_number: -5 }).success).toBe(false)
expect(UpdateSettingsSchema.safeParse({ next_arrival_number: 1.5 }).success).toBe(false)
})
it('accepts vat_registered: true with required vat_number and moms_period', () => {
const result = UpdateSettingsSchema.safeParse({
vat_registered: true,
@@ -2030,6 +2044,25 @@ describe('UpdateAccountSchema', () => {
const result = UpdateAccountSchema.safeParse({ is_active: 'yes' })
expect(result.success).toBe(false)
})
it('accepts a valid default_vat_rate (0/0.06/0.12/0.25/null)', () => {
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0 }).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0.06 }).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0.12 }).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0.25 }).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_rate: null }).success).toBe(true)
})
it('rejects a default_vat_rate outside the allowed set', () => {
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0.2 }).success).toBe(false)
expect(CreateAccountSchema.safeParse({
account_number: '3740',
account_name: 'Öres- och kronutjämning',
account_type: 'revenue',
normal_balance: 'debit',
default_vat_rate: 0.5,
}).success).toBe(false)
})
})
// ============================================================
+10
View File
@@ -1368,6 +1368,7 @@ export const UpdateSettingsSchema = z.object({
accounting_method: AccountingMethodSchema.optional(),
invoice_prefix: z.string().nullable().optional(),
next_invoice_number: z.number().int().positive().optional(),
next_arrival_number: z.number().int().positive().optional(),
invoice_default_days: z.number().int().positive().optional(),
invoice_default_notes: z.string().nullable().optional(),
default_our_reference: z.string().max(200).nullable().optional(),
@@ -1540,6 +1541,13 @@ export const CreateDeadlineSchema = z.object({
// Account schemas
// ============================================================
// Per-account default VAT rate: the sats the booking UI understands, as a
// decimal fraction. Mirrors the DB CHECK on chart_of_accounts.default_vat_rate.
const defaultVatRate = z
.union([z.literal(0), z.literal(0.06), z.literal(0.12), z.literal(0.25)])
.nullable()
.optional()
export const CreateAccountSchema = z.object({
account_number: accountNumber,
account_name: z.string().min(1, 'Account name is required'),
@@ -1548,6 +1556,7 @@ export const CreateAccountSchema = z.object({
plan_type: z.enum(['k1', 'full_bas']).optional(),
description: z.string().nullable().optional(),
default_vat_code: z.string().nullable().optional(),
default_vat_rate: defaultVatRate,
sru_code: z.string().nullable().optional(),
})
@@ -1556,6 +1565,7 @@ export const UpdateAccountSchema = z.object({
is_active: z.boolean().optional(),
description: z.string().nullable().optional(),
default_vat_code: z.string().nullable().optional(),
default_vat_rate: defaultVatRate,
sru_code: z.string().nullable().optional(),
})
@@ -31,6 +31,7 @@ function makeBASAccount(number: string, name: string): BASAccount {
is_active: true,
is_system_account: false,
default_vat_code: null,
default_vat_rate: null,
description: null,
sru_code: null,
k2_excluded: false,
+2
View File
@@ -1340,6 +1340,8 @@
"prefix_label": "Invoice prefix",
"prefix_placeholder": "e.g. F-",
"next_number_label": "Next invoice number",
"arrival_start_label": "Supplier invoice start number",
"arrival_start_help": "The arrival number (ankomstnummer) your first supplier invoice gets. Set it once to continue the series from a previous system such as Fortnox. After that it increments automatically.",
"default_days_label": "Payment terms (days)",
"default_notes_label": "Default invoice text",
"default_notes_placeholder": "E.g. payment terms, delivery info...",
+2
View File
@@ -1340,6 +1340,8 @@
"prefix_label": "Fakturaprefix",
"prefix_placeholder": "t.ex. F-",
"next_number_label": "Nästa fakturanummer",
"arrival_start_label": "Startnummer för leverantörsfakturor",
"arrival_start_help": "Ankomstnumret som din första leverantörsfaktura får. Sätt det en gång för att fortsätta serien från ett tidigare system, till exempel Fortnox. Därefter räknas numret upp automatiskt.",
"default_days_label": "Betalningsvillkor (dagar)",
"default_notes_label": "Standardtext på fakturor",
"default_notes_placeholder": "T.ex. betalningsvillkor, leveransinfo...",
@@ -0,0 +1,54 @@
-- Per-account default VAT rate ("Standard moms") on the chart of accounts.
--
-- Lets a company decide, once per konto, which moms-sats a booking line should
-- default to when that konto is picked. The motivating case is oresavrundning
-- (konto 3740, Ores- och kronutjamning): a rounding line must never carry moms,
-- but the leverantorsfaktura-editor defaulted every rad to 25 %, so the moms and
-- the rounding came out wrong. NULL keeps today's behaviour (no auto-fill);
-- 0 = ingen moms. Stored as a decimal fraction to match how the app carries VAT
-- rates everywhere else (0 / 0.06 / 0.12 / 0.25).
ALTER TABLE public.chart_of_accounts
ADD COLUMN IF NOT EXISTS default_vat_rate numeric;
-- Constrain to the sats the app understands; NULL stays allowed (no default).
ALTER TABLE public.chart_of_accounts
DROP CONSTRAINT IF EXISTS chart_of_accounts_default_vat_rate_check;
ALTER TABLE public.chart_of_accounts
ADD CONSTRAINT chart_of_accounts_default_vat_rate_check
CHECK (default_vat_rate IS NULL OR default_vat_rate IN (0, 0.06, 0.12, 0.25));
COMMENT ON COLUMN public.chart_of_accounts.default_vat_rate IS
'Per-account default VAT rate for booking lines (0/0.06/0.12/0.25). NULL = no default. Oresavrundning (3740) ships as 0 (momsfri).';
-- Existing companies: mark oresavrundning (3740) as momsfri so it stops
-- inheriting phantom moms. Only touches rows without an explicit value.
UPDATE public.chart_of_accounts
SET default_vat_rate = 0
WHERE account_number = '3740' AND default_vat_rate IS NULL;
-- New / imported / on-demand 3740 rows: ship momsfri too, whatever the insert
-- path (company seed, SIE import, on-demand backfill, manual add). The chart
-- seed function does not write a VAT column and 3740 is added on demand, so a
-- BEFORE INSERT default is the one place that covers every path. Fires only
-- when the caller left the rate unset, so an explicit choice always wins.
CREATE OR REPLACE FUNCTION public.set_known_momsfri_default_vat_rate()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
IF NEW.default_vat_rate IS NULL AND NEW.account_number = '3740' THEN
NEW.default_vat_rate := 0;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_chart_of_accounts_momsfri_default ON public.chart_of_accounts;
CREATE TRIGGER trg_chart_of_accounts_momsfri_default
BEFORE INSERT ON public.chart_of_accounts
FOR EACH ROW
EXECUTE FUNCTION public.set_known_momsfri_default_vat_rate();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,67 @@
-- Configurable starting number for the supplier-invoice series (ankomstnummer).
--
-- Mirrors next_invoice_number for customer invoices: lets a company continue
-- its leverantorsfaktura numbering from a previous system (e.g. Fortnox) when
-- migrating to Accounted, instead of always restarting the ankomstnummer at 1.
--
-- Design: a start FLOOR, not a consumed counter. get_next_arrival_number keeps
-- its self-healing COALESCE(MAX(arrival_number),0)+1 behavior and floors the
-- result at next_arrival_number via GREATEST. Consequences:
-- * Existing companies default to 1, so MAX+1 is unchanged.
-- * Before the first invoice, the series starts at the configured value.
-- * Once real invoices pass the floor, MAX+1 dominates: the floor can never
-- move the series backwards or collide with the
-- (company_id, arrival_number) unique index.
--
-- The function is also hardened while rewritten (it was SECURITY DEFINER with a
-- mutable search_path, flagged by the DB linter): SET search_path = '', all
-- references schema-qualified, plus an inline membership check mirroring
-- generate_invoice_number. NULL auth.uid() (service role / API-key / cron paths
-- that call this RPC) is trusted through.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS next_arrival_number integer NOT NULL DEFAULT 1;
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_next_arrival_number_positive;
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_next_arrival_number_positive
CHECK (next_arrival_number >= 1);
CREATE OR REPLACE FUNCTION public.get_next_arrival_number(p_company_id uuid)
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_floor integer;
v_next integer;
BEGIN
-- Defense-in-depth: refuse to operate on companies the caller is not a
-- member of. NULL auth.uid() (service role / API-key / cron) is trusted
-- through, matching generate_invoice_number.
IF auth.uid() IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = auth.uid() AND company_id = p_company_id
) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
-- Configured start floor (defaults to 1 for every company; NULL only if the
-- settings row is missing, in which case COALESCE keeps the old behavior).
SELECT COALESCE(next_arrival_number, 1) INTO v_floor
FROM public.company_settings
WHERE company_id = p_company_id;
SELECT GREATEST(COALESCE(MAX(arrival_number), 0) + 1, COALESCE(v_floor, 1))
INTO v_next
FROM public.supplier_invoices
WHERE company_id = p_company_id;
RETURN v_next;
END;
$function$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
/**
* Locks in the behaviour of chart_of_accounts.default_vat_rate
* (migration 20260709120000):
*
* - a CHECK constraint keeps the rate to the sats the app understands
* (0 / 0.06 / 0.12 / 0.25) or NULL;
* - a BEFORE INSERT trigger ships öresavrundning (3740) as momsfri (0) on
* every insert path (company seed, SIE import, on-demand backfill, manual
* add), so a rounding line never inherits phantom moms;
* - the trigger only fills an unset rate, so an explicit choice always wins;
* - other accounts keep NULL (today's behaviour: no auto-fill).
*/
async function insertAccount(
companyId: string,
userId: string,
account: {
number: string
name: string
type: string
balance?: 'debit' | 'credit'
rate?: number | null
},
): Promise<number | null> {
const res = await getPool().query<{ default_vat_rate: string | null }>(
`INSERT INTO public.chart_of_accounts
(user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, plan_type,
is_system_account, default_vat_rate)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'full_bas', false, $9)
RETURNING default_vat_rate`,
[
userId,
companyId,
account.number,
account.name,
Number(account.number[0]),
account.number.slice(0, 2),
account.type,
account.balance ?? 'debit',
account.rate ?? null,
],
)
const raw = res.rows[0].default_vat_rate
return raw === null ? null : Number(raw)
}
describe('chart_of_accounts.default_vat_rate', () => {
it('ships öresavrundning (3740) as momsfri (0) when the rate is unset', async () => {
const { companyId, userId } = await seedCompany()
const rate = await insertAccount(companyId, userId, {
number: '3740',
name: 'Öres- och kronutjämning',
type: 'revenue',
})
expect(rate).toBe(0)
})
it('keeps an explicit rate on 3740 (trigger only fills an unset rate)', async () => {
const { companyId, userId } = await seedCompany()
const rate = await insertAccount(companyId, userId, {
number: '3740',
name: 'Öres- och kronutjämning',
type: 'revenue',
rate: 0.25,
})
expect(rate).toBe(0.25)
})
it('leaves other accounts with no default (NULL)', async () => {
const { companyId, userId } = await seedCompany()
const rate = await insertAccount(companyId, userId, {
number: '5010',
name: 'Lokalhyra',
type: 'expense',
})
expect(rate).toBeNull()
})
it('accepts every allowed sats', async () => {
const { companyId, userId } = await seedCompany()
for (const [i, r] of [0, 0.06, 0.12, 0.25].entries()) {
const rate = await insertAccount(companyId, userId, {
number: `60${i}0`,
name: `Konto ${i}`,
type: 'expense',
rate: r,
})
expect(rate).toBe(r)
}
})
it('rejects a rate outside the allowed set (CHECK constraint)', async () => {
const { companyId, userId } = await seedCompany()
await expect(
insertAccount(companyId, userId, {
number: '5020',
name: 'Felaktig sats',
type: 'expense',
rate: 0.2,
}),
).rejects.toThrow()
})
})
+1
View File
@@ -538,6 +538,7 @@ export function makeCompanySettings(
accounting_method: 'accrual',
invoice_prefix: 'F',
next_invoice_number: 1,
next_arrival_number: 1,
next_delivery_note_number: 1,
invoice_default_days: 30,
invoice_default_notes: null,
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Covers 20260709130000_supplier_invoice_start_number:
* - next_arrival_number acts as a start FLOOR for the supplier-invoice
* (ankomstnummer) series: get_next_arrival_number returns
* GREATEST(MAX(arrival_number)+1, next_arrival_number).
* - Default 1 preserves the old MAX+1 behavior.
* - The floor never moves the series backwards once real invoices pass it.
* - The hardened RPC rejects callers who are not company members
* (auth.uid() not null), and lets members through.
*/
async function ensureSettings(
userId: string,
companyId: string,
nextArrivalNumber: number,
): Promise<void> {
await getPool().query(
`INSERT INTO public.company_settings (user_id, company_id, next_arrival_number)
VALUES ($1, $2, $3)
ON CONFLICT (company_id)
DO UPDATE SET next_arrival_number = EXCLUDED.next_arrival_number`,
[userId, companyId, nextArrivalNumber],
)
}
async function insertSupplier(userId: string, companyId: string): Promise<string> {
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.suppliers (user_id, company_id, name)
VALUES ($1, $2, 'Test Supplier') RETURNING id`,
[userId, companyId],
)
return rows[0]!.id
}
async function insertSupplierInvoice(params: {
userId: string
companyId: string
supplierId: string
arrivalNumber: number
}): Promise<void> {
await getPool().query(
`INSERT INTO public.supplier_invoices
(user_id, company_id, supplier_id, arrival_number,
supplier_invoice_number, invoice_date, due_date)
VALUES ($1, $2, $3, $4, $5, '2026-06-01', '2026-06-30')`,
[
params.userId,
params.companyId,
params.supplierId,
params.arrivalNumber,
`INV-${params.arrivalNumber}`,
],
)
}
async function nextArrival(companyId: string): Promise<number> {
const { rows } = await getPool().query<{ n: number }>(
'SELECT public.get_next_arrival_number($1) AS n',
[companyId],
)
return rows[0]!.n
}
describe('get_next_arrival_number: configurable start floor', () => {
it('returns 1 when there are no invoices and no settings row', async () => {
const { companyId } = await seedCompany()
expect(await nextArrival(companyId)).toBe(1)
})
it('returns 1 when the floor is the default and no invoices exist', async () => {
const { userId, companyId } = await seedCompany()
await ensureSettings(userId, companyId, 1)
expect(await nextArrival(companyId)).toBe(1)
})
it('starts the series at the configured floor when no invoices exist', async () => {
const { userId, companyId } = await seedCompany()
await ensureSettings(userId, companyId, 248)
expect(await nextArrival(companyId)).toBe(248)
})
it('continues MAX+1 once an invoice reaches the floor', async () => {
const { userId, companyId } = await seedCompany()
await ensureSettings(userId, companyId, 248)
const supplierId = await insertSupplier(userId, companyId)
await insertSupplierInvoice({ userId, companyId, supplierId, arrivalNumber: 248 })
expect(await nextArrival(companyId)).toBe(249)
})
it('ignores a floor set below the current MAX (never moves backwards)', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
await insertSupplierInvoice({ userId, companyId, supplierId, arrivalNumber: 300 })
await ensureSettings(userId, companyId, 248)
expect(await nextArrival(companyId)).toBe(301)
})
it('is scoped per company (one company floor does not leak into another)', async () => {
const a = await seedCompany()
const b = await seedCompany()
await ensureSettings(a.userId, a.companyId, 500)
await ensureSettings(b.userId, b.companyId, 1)
expect(await nextArrival(a.companyId)).toBe(500)
expect(await nextArrival(b.companyId)).toBe(1)
})
})
describe('get_next_arrival_number: membership hardening', () => {
it('rejects a caller who is not a member of the target company', async () => {
const intruder = await seedCompany()
const target = await seedCompany()
await ensureSettings(target.userId, target.companyId, 10)
await expect(
withUserContext(intruder.userId, async (client) => {
await client.query('SELECT public.get_next_arrival_number($1)', [target.companyId])
}),
).rejects.toThrow(/unauthorized/i)
})
it('allows a member of the target company', async () => {
const { userId, companyId } = await seedCompany()
await ensureSettings(userId, companyId, 7)
const result = await withUserContext(userId, async (client) => {
const { rows } = await client.query<{ n: number }>(
'SELECT public.get_next_arrival_number($1) AS n',
[companyId],
)
return rows[0]!.n
})
expect(result).toBe(7)
})
it('trusts service-role callers (auth.uid() null) through the guard', async () => {
// Pool queries run as superuser with no JWT claims, so auth.uid() is NULL:
// the membership check is skipped, mirroring API-key / cron paths.
const { companyId } = await seedCompany()
expect(typeof (await nextArrival(companyId))).toBe('number')
})
})
+7
View File
@@ -258,6 +258,10 @@ export interface CompanySettings {
// Invoice settings
invoice_prefix: string | null
next_invoice_number: number
// Starting ankomstnummer for the supplier-invoice (leverantorsfaktura)
// series. Acts as a floor: get_next_arrival_number returns
// GREATEST(MAX(arrival_number)+1, next_arrival_number). Defaults to 1.
next_arrival_number: number
next_delivery_note_number: number
invoice_default_days: number
invoice_default_notes: string | null
@@ -1394,6 +1398,9 @@ export interface BASAccount {
is_active: boolean
is_system_account: boolean
default_vat_code: string | null
// Per-account default VAT rate for booking lines (0/0.06/0.12/0.25).
// null = no default (line keeps its own rate). Öresavrundning (3740) = 0.
default_vat_rate: number | null
description: string | null
sru_code: string | null
k2_excluded: boolean