feat(invoices): implement öresavrundning logic and next invoice numbe… (#429)
* feat(invoices): implement öresavrundning logic and next invoice number preview - Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings. - Updated `InvoicesPage` to utilize the new rounding logic when displaying totals. - Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence. - Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits. - Enhanced tests for invoice number generation and rounding functionality to ensure correctness. - Updated PDF template to reflect new rounding logic for totals and display appropriate values. - Adjusted company switcher to hide options in sandbox mode. - Improved error handling and logging in sandbox seeding process. * fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
-- Drop the unconditional year prefix from generate_invoice_number().
|
||||
--
|
||||
-- The previous version (20260427150100) always inserted EXTRACT(YEAR FROM
|
||||
-- CURRENT_DATE) between the company prefix and the sequence number. That
|
||||
-- silently overrode the user's "Nästa fakturanummer" setting: a customer
|
||||
-- migrating from another system who set next_invoice_number = 10159 would
|
||||
-- get '2026<n>' instead of '10159'. There was no way to opt out of the
|
||||
-- year injection short of leaving prefix=NULL and accepting the surprise.
|
||||
--
|
||||
-- New format:
|
||||
-- proforma -> 'PF-' || LPAD(number::text, 3, '0')
|
||||
-- otherwise -> COALESCE(invoice_prefix, '') || LPAD(number::text, 3, '0')
|
||||
--
|
||||
-- Customers who *want* a year prefix put it in invoice_prefix explicitly
|
||||
-- (e.g. 'F-2026-' or '2026'). LPAD pads small numbers but never truncates,
|
||||
-- so bumping next_invoice_number to a high value continues to render the
|
||||
-- full number.
|
||||
--
|
||||
-- Backfill: if a company has 2+ existing invoices whose numbers match the
|
||||
-- old year-prefixed format (^\d{4}\d+$) and shares a single year, backfill
|
||||
-- invoice_prefix to that year so their next invoice keeps visual
|
||||
-- continuity. Single-invoice companies are skipped — they're likely fresh
|
||||
-- migrators (like C by Sea) whose first invoice was the buggy year-prefix
|
||||
-- output, and forcing the prefix on them would defeat the fix.
|
||||
|
||||
DROP FUNCTION IF EXISTS public.generate_invoice_number(uuid, uuid, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.generate_invoice_number(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid,
|
||||
p_document_type text DEFAULT 'invoice'
|
||||
)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_existing text;
|
||||
v_prefix text;
|
||||
v_number integer;
|
||||
v_final text;
|
||||
BEGIN
|
||||
-- 1. Lock the invoice row. Concurrent callers block here until the first
|
||||
-- transaction commits, then see the persisted number on retry.
|
||||
SELECT invoice_number INTO v_existing
|
||||
FROM public.invoices
|
||||
WHERE id = p_invoice_id AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Invoice % not found in company %', p_invoice_id, p_company_id;
|
||||
END IF;
|
||||
|
||||
-- 2. Idempotent: if the number is already set, return it without consuming
|
||||
-- the sequence. This is also the path concurrent callers take after
|
||||
-- unblocking from the row lock.
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN v_existing;
|
||||
END IF;
|
||||
|
||||
-- 3. Allocate from per-company counter atomically. UPDATE ... RETURNING is
|
||||
-- serialized by Postgres on the company_settings row.
|
||||
UPDATE public.company_settings
|
||||
SET next_invoice_number = next_invoice_number + 1,
|
||||
updated_at = now()
|
||||
WHERE company_id = p_company_id
|
||||
RETURNING invoice_prefix, next_invoice_number - 1
|
||||
INTO v_prefix, v_number;
|
||||
|
||||
IF v_number IS NULL THEN
|
||||
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
|
||||
END IF;
|
||||
|
||||
-- 4. Compose: proforma -> 'PF-', otherwise the company's invoice_prefix.
|
||||
-- No year injection — the prefix is the only place it can live.
|
||||
v_final := CASE
|
||||
WHEN p_document_type = 'proforma' THEN 'PF-'
|
||||
ELSE COALESCE(v_prefix, '')
|
||||
END || LPAD(v_number::text, 3, '0');
|
||||
|
||||
-- 5. Persist on the invoice row in the same transaction.
|
||||
UPDATE public.invoices
|
||||
SET invoice_number = v_final
|
||||
WHERE id = p_invoice_id AND company_id = p_company_id;
|
||||
|
||||
RETURN v_final;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- Backfill: preserve visual continuity for established companies that
|
||||
-- relied on the implicit year prefix. Only touch companies with 2+ existing
|
||||
-- invoices that all share a single 4-digit year prefix and currently have
|
||||
-- invoice_prefix=NULL.
|
||||
UPDATE public.company_settings cs
|
||||
SET invoice_prefix = sub.year_str,
|
||||
updated_at = now()
|
||||
FROM (
|
||||
SELECT i.company_id,
|
||||
(regexp_match(i.invoice_number, '^(\d{4})\d+$'))[1] AS year_str,
|
||||
COUNT(*) AS hits
|
||||
FROM public.invoices i
|
||||
WHERE i.invoice_number ~ '^\d{4}\d+$'
|
||||
GROUP BY i.company_id, (regexp_match(i.invoice_number, '^(\d{4})\d+$'))[1]
|
||||
HAVING COUNT(*) >= 2
|
||||
) sub
|
||||
WHERE cs.company_id = sub.company_id
|
||||
AND cs.invoice_prefix IS NULL
|
||||
-- If a company has invoices spanning multiple years (e.g. 2025001 and
|
||||
-- 2026001), the subquery returns a row per year; pick the most recent.
|
||||
AND sub.year_str = (
|
||||
SELECT (regexp_match(i2.invoice_number, '^(\d{4})\d+$'))[1]
|
||||
FROM public.invoices i2
|
||||
WHERE i2.company_id = cs.company_id
|
||||
AND i2.invoice_number ~ '^\d{4}\d+$'
|
||||
ORDER BY i2.invoice_date DESC NULLS LAST, i2.created_at DESC
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Peek the next invoice number without consuming the sequence.
|
||||
--
|
||||
-- generate_invoice_number() atomically increments and persists, which is
|
||||
-- the right behavior at send/save time but unsuitable for previewing in
|
||||
-- the UI. peek_next_invoice_number() reads the same fields and applies the
|
||||
-- same composition rules (matching the no-year-prefix format from
|
||||
-- 20260510120000) without modifying state.
|
||||
--
|
||||
-- Important: this is a preview only. Two callers reading concurrently
|
||||
-- might both see the same number; the actual allocator (generate_…) is
|
||||
-- the source of truth and assigns atomically. The UI re-fetches before
|
||||
-- submit so the preview reflects fresh state.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.peek_next_invoice_number(
|
||||
p_company_id uuid,
|
||||
p_document_type text DEFAULT 'invoice'
|
||||
)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
SELECT CASE
|
||||
WHEN p_document_type = 'proforma' THEN 'PF-'
|
||||
ELSE COALESCE(invoice_prefix, '')
|
||||
END || LPAD(next_invoice_number::text, 3, '0')
|
||||
FROM public.company_settings
|
||||
WHERE company_id = p_company_id
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,87 @@
|
||||
-- Fix LPAD truncation in invoice number generation.
|
||||
--
|
||||
-- Postgres LPAD(string, length [, fill]) TRUNCATES on the right when string
|
||||
-- is longer than length. So LPAD('10159', 3, '0') returns '101' — not the
|
||||
-- '10159' the user expected. The previous migration (20260510120000)
|
||||
-- preserved this LPAD pattern from the original 20260427150100 function
|
||||
-- on the assumption that LPAD never truncates; that was wrong.
|
||||
--
|
||||
-- The customer-visible symptom: setting next_invoice_number = 10159 with
|
||||
-- no prefix produces invoice number '101' instead of '10159', and the
|
||||
-- preview surfaced the same '101'.
|
||||
--
|
||||
-- Fix: pad to at LEAST three digits, but never shorter than the actual
|
||||
-- number. GREATEST(3, length(...)) is the simplest way to express that.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.generate_invoice_number(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid,
|
||||
p_document_type text DEFAULT 'invoice'
|
||||
)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_existing text;
|
||||
v_prefix text;
|
||||
v_number integer;
|
||||
v_final text;
|
||||
BEGIN
|
||||
SELECT invoice_number INTO v_existing
|
||||
FROM public.invoices
|
||||
WHERE id = p_invoice_id AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Invoice % not found in company %', p_invoice_id, p_company_id;
|
||||
END IF;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN v_existing;
|
||||
END IF;
|
||||
|
||||
UPDATE public.company_settings
|
||||
SET next_invoice_number = next_invoice_number + 1,
|
||||
updated_at = now()
|
||||
WHERE company_id = p_company_id
|
||||
RETURNING invoice_prefix, next_invoice_number - 1
|
||||
INTO v_prefix, v_number;
|
||||
|
||||
IF v_number IS NULL THEN
|
||||
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
|
||||
END IF;
|
||||
|
||||
v_final := CASE
|
||||
WHEN p_document_type = 'proforma' THEN 'PF-'
|
||||
ELSE COALESCE(v_prefix, '')
|
||||
END || LPAD(v_number::text, GREATEST(3, length(v_number::text)), '0');
|
||||
|
||||
UPDATE public.invoices
|
||||
SET invoice_number = v_final
|
||||
WHERE id = p_invoice_id AND company_id = p_company_id;
|
||||
|
||||
RETURN v_final;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.peek_next_invoice_number(
|
||||
p_company_id uuid,
|
||||
p_document_type text DEFAULT 'invoice'
|
||||
)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
SELECT CASE
|
||||
WHEN p_document_type = 'proforma' THEN 'PF-'
|
||||
ELSE COALESCE(invoice_prefix, '')
|
||||
END || LPAD(next_invoice_number::text, GREATEST(3, length(next_invoice_number::text)), '0')
|
||||
FROM public.company_settings
|
||||
WHERE company_id = p_company_id
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user