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:
+29
-9
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user