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:
Mattsson
2026-05-10 14:21:26 +02:00
committed by GitHub
parent c110d3ef99
commit 0ee5219b6c
19 changed files with 733 additions and 106 deletions
+29 -4
View File
@@ -12,6 +12,7 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import {
Loader2,
ArrowLeft,
@@ -85,6 +86,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [isDownloading, setIsDownloading] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [oreRounding, setOreRounding] = useState<boolean>(true)
useEffect(() => {
fetchInvoice()
@@ -120,6 +122,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setInvoice(data as InvoiceWithRelations)
// Fetch the öresavrundning setting so the detail view matches the PDF.
if (data.company_id) {
const { data: settings } = await supabase
.from('company_settings')
.select('ore_rounding')
.eq('company_id', data.company_id)
.maybeSingle()
setOreRounding(settings?.ore_rounding ?? true)
}
// Fetch reminders for this invoice
const { data: reminderData } = await supabase
.from('invoice_reminders')
@@ -579,10 +591,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
))
})()}
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{formatCurrency(invoice.total, invoice.currency)}</span>
</div>
{(() => {
const rounding = getDisplayTotal(invoice, { ore_rounding: oreRounding })
return (
<>
{rounding.applies && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>Öresavrundning</span>
<span>{formatCurrency(rounding.roundingDelta, 'SEK')}</span>
</div>
)}
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{formatCurrency(rounding.displayed, invoice.currency)}</span>
</div>
</>
)
})()}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>I SEK (kurs {invoice.exchange_rate})</span>
+44 -1
View File
@@ -79,6 +79,7 @@ export default function NewInvoicePage() {
const [hasBankDetails, setHasBankDetails] = useState<boolean | null>(null)
const [showBankSetup, setShowBankSetup] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const [numberPreview, setNumberPreview] = useState<string | null>(null)
const pendingCustomerRef = useRef<Customer | null>(null)
const {
@@ -153,6 +154,29 @@ export default function NewInvoicePage() {
}
}
// Preview the next invoice number so the user can catch a mis-set
// sequence/prefix before committing. The actual allocator still runs
// atomically at create time; this is read-only.
useEffect(() => {
if (!company?.id) return
if (watchDocumentType === 'delivery_note') {
setNumberPreview(null)
return
}
let cancelled = false
fetch(`/api/invoices/next-number?document_type=${encodeURIComponent(watchDocumentType)}`)
.then((r) => (r.ok ? r.json() : null))
.then((res) => {
if (!cancelled) setNumberPreview(res?.data?.preview ?? null)
})
.catch(() => {
if (!cancelled) setNumberPreview(null)
})
return () => {
cancelled = true
}
}, [company?.id, watchDocumentType])
useEffect(() => {
if (watchCustomerId) {
const customer = customers.find((c) => c.id === watchCustomerId)
@@ -257,8 +281,21 @@ export default function NewInvoicePage() {
}
const total = subtotal + vatAmount
function onSubmit(data: FormData) {
async function onSubmit(data: FormData) {
setPendingData(data)
// Re-fetch the preview right before review so the displayed number
// reflects any concurrent invoice creations.
if (data.document_type !== 'delivery_note') {
try {
const r = await fetch(`/api/invoices/next-number?document_type=${encodeURIComponent(data.document_type)}`)
if (r.ok) {
const json = await r.json()
setNumberPreview(json?.data?.preview ?? null)
}
} catch {
// Preview is best-effort; the allocator at create time is the source of truth.
}
}
if (hasBankDetails === false && watchDocumentType === 'invoice') {
setShowBankSetup(true)
return
@@ -405,6 +442,11 @@ export default function NewInvoicePage() {
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{watchDocumentType === 'proforma' ? 'Ny proformafaktura' : watchDocumentType === 'delivery_note' ? 'Ny följesedel' : 'Ny faktura'}
{numberPreview && (
<span className="ml-2 text-muted-foreground tabular-nums text-xl md:text-2xl">
({numberPreview})
</span>
)}
</h1>
<p className="text-muted-foreground">
{watchDocumentType === 'proforma' ? 'Skapa en proformafaktura (ingen bokföring)' : watchDocumentType === 'delivery_note' ? 'Skapa en följesedel (utan priser)' : 'Skapa en ny faktura'}
@@ -853,6 +895,7 @@ export default function NewInvoicePage() {
yourReference={pendingData?.your_reference}
ourReference={pendingData?.our_reference}
notes={pendingData?.notes}
numberPreview={numberPreview}
/>
</ConfirmationDialog>
)}
+21 -8
View File
@@ -14,6 +14,7 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { Plus, Search, Receipt, Lock } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import { useCompany } from '@/contexts/CompanyContext'
@@ -55,6 +56,7 @@ export default function InvoicesPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const [invoices, setInvoices] = useState<Invoice[]>([])
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('all')
@@ -64,21 +66,29 @@ export default function InvoicesPage() {
async function fetchInvoices() {
if (!company) return
setIsLoading(true)
const { data, error } = await supabase
.from('invoices')
.select('*, customer:customers(name)')
.eq('company_id', company.id)
.order('invoice_date', { ascending: false })
const [invoicesResult, settingsResult] = await Promise.all([
supabase
.from('invoices')
.select('*, customer:customers(name)')
.eq('company_id', company.id)
.order('invoice_date', { ascending: false }),
supabase
.from('company_settings')
.select('ore_rounding')
.eq('company_id', company.id)
.maybeSingle(),
])
if (error) {
if (invoicesResult.error) {
toast({
title: 'Kunde inte ladda fakturor',
description: 'Kontrollera din anslutning och försök igen.',
variant: 'destructive',
})
} else {
setInvoices(data || [])
setInvoices(invoicesResult.data || [])
}
setOreRounding(settingsResult.data?.ore_rounding ?? true)
setIsLoading(false)
}
@@ -293,7 +303,10 @@ export default function InvoicesPage() {
<div className="flex items-start sm:items-center justify-between gap-2">
<p className={cn('font-medium truncate', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</p>
<p className={`font-medium tabular-nums shrink-0 ${isCreditNote ? 'text-destructive' : ''}`}>
{formatCurrency(Number(invoice.total), invoice.currency)}
{formatCurrency(
getDisplayTotal({ total: Number(invoice.total), currency: invoice.currency }, { ore_rounding: oreRounding }).displayed,
invoice.currency,
)}
</p>
</div>
<p className="text-sm text-muted-foreground truncate">
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
export const GET = withRouteContext(
'invoice.peek_next_number',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const documentType = url.searchParams.get('document_type') ?? 'invoice'
if (!['invoice', 'proforma', 'delivery_note'].includes(documentType)) {
return NextResponse.json(
{ error: 'invalid document_type', requestId },
{ status: 400 },
)
}
// delivery_note has its own sequence (generate_delivery_note_number); the
// peek RPC only covers the invoice/proforma F-series counter, so for
// delivery notes we return null and let the form skip the preview.
if (documentType === 'delivery_note') {
return NextResponse.json({ data: { preview: null } })
}
const { data, error } = await supabase.rpc('peek_next_invoice_number', {
p_company_id: companyId,
p_document_type: documentType,
})
if (error) {
log.error('peek_next_invoice_number failed', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data: { preview: data ?? null } })
},
)
+30 -3
View File
@@ -1,6 +1,9 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { getActiveCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger'
const log = createLogger('sandbox:seed')
/**
* POST /api/sandbox/seed
@@ -19,7 +22,30 @@ export async function POST() {
return NextResponse.json({ error: 'Sandbox is only available for anonymous users' }, { status: 403 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Anonymous users start with no company. Create one before seeding.
// If a previous seed attempt already created a company for this user, reuse it
// (idempotency).
let companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) {
const { data: newCompanyId, error: companyError } = await supabase.rpc(
'create_company_with_owner',
{
p_name: 'Sandlådan Konsult',
p_entity_type: 'enskild_firma',
}
)
if (companyError || !newCompanyId) {
log.error('failed to create sandbox company', { error: companyError, userId: user.id })
return NextResponse.json(
{ error: 'Failed to create sandbox company' },
{ status: 500 }
)
}
companyId = newCompanyId as string
}
// Idempotency: if already seeded, return early
const { data: existing } = await supabase
@@ -523,7 +549,8 @@ export async function POST() {
if (dlError) throw dlError
return NextResponse.json({ seeded: true })
} catch {
} catch (err) {
log.error('failed to seed sandbox data', { error: err, userId: user.id, companyId })
return NextResponse.json(
{ error: 'Failed to seed sandbox data' },
{ status: 500 }
+15 -12
View File
@@ -9,7 +9,7 @@ import { switchCompany } from '@/lib/company/actions'
import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
export default function CompanySwitcher() {
const { company, companies } = useCompany()
const { company, companies, isSandbox } = useCompany()
const [open, setOpen] = useState(false)
const [isPending, setIsPending] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
@@ -106,8 +106,9 @@ export default function CompanySwitcher() {
const hasMultiple = companies.length > 1
// No companies yet — show a direct "Lägg till företag" link instead of
// the switcher so the user can still create one.
// the switcher so the user can still create one. Hidden in sandbox mode.
if (!company && companies.length === 0) {
if (isSandbox) return null
return (
<Link
href="/select-company"
@@ -187,16 +188,18 @@ export default function CompanySwitcher() {
</>
)}
<div className={cn(companies.length > 0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
<Link
href="/select-company"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap"
>
<Plus className="h-3.5 w-3.5" />
Lägg till företag
</Link>
</div>
{!isSandbox && (
<div className={cn(companies.length > 0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
<Link
href="/select-company"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap"
>
<Plus className="h-3.5 w-3.5" />
Lägg till företag
</Link>
</div>
)}
</div>,
document.body
)}
@@ -25,6 +25,9 @@ interface InvoiceReviewContentProps {
yourReference?: string
ourReference?: string
notes?: string
/** The invoice number that will be assigned on confirm. Null when unknown
* (e.g. delivery notes use a different sequence) or unfetched. */
numberPreview?: string | null
}
export function InvoiceReviewContent({
@@ -39,6 +42,7 @@ export function InvoiceReviewContent({
yourReference,
ourReference,
notes,
numberPreview,
}: InvoiceReviewContentProps) {
const customerTypeLabel: Record<string, string> = {
individual: 'Privatperson',
@@ -60,6 +64,12 @@ export function InvoiceReviewContent({
return (
<div className="space-y-4">
{numberPreview && (
<div className="text-sm text-muted-foreground">
Tilldelas fakturanummer{' '}
<span className="font-medium tabular-nums text-foreground">{numberPreview}</span>
</div>
)}
{/* Customer info */}
<div className="bg-muted rounded-lg p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="min-w-0">
+48 -42
View File
@@ -734,55 +734,61 @@ export function AGIPanel(props: AGIPanelProps) {
)}
{!readOnly && !isSigned && (
<>
{/* Direct AGI submission to Skatteverket is paused while the
APIGW subscription is sorted out at SKV's end. Users still
generate and download the AGI XML from the salary run page
and upload it manually via Mina Sidor. Re-enable the three
buttons below once the subscription is in place. */}
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">
Direktinlämning till Skatteverket är pausad
</p>
<p className="mt-1 text-xs text-muted-foreground">
Ladda ner AGI-filen ovan och lämna in den manuellt via Mina Sidor
hos Skatteverket. Direktinlämning aktiveras igen när vår
anslutning hos Skatteverket är klar.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled
title="Direktinlämning till Skatteverket är pausad"
>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading === 'submit'}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
Skicka in underlag
</Button>
<Button
size="sm"
onClick={handleCreateSigningLink}
disabled
title="Direktinlämning till Skatteverket är pausad"
>
)}
Skicka in underlag
</Button>
<Button
size="sm"
onClick={handleCreateSigningLink}
disabled={actionLoading === 'granskning' || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
Skapa signeringslänk
</Button>
)}
Skapa signeringslänk
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading === 'check'}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
Hämta kvittens
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled
title="Direktinlämning till Skatteverket är pausad"
onClick={handleUnlock}
disabled={actionLoading === 'unlock'}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
Hämta kvittens
{actionLoading === 'unlock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
Lås upp
</Button>
</div>
</>
)}
</div>
)}
</CardContent>
</Card>
@@ -24,8 +24,6 @@ type Status =
const SCOPE_LABELS: Record<string, string> = {
momsdeklaration: 'Momsdeklaration',
inkforetag: 'Företagsinformation',
ska: 'Skatteinformation',
skahmst: 'Hemortskommun',
skattekonto: 'Skattekonto',
agd: 'Arbetsgivardeklaration',
}
@@ -35,21 +35,48 @@ beforeEach(() => {
vi.restoreAllMocks()
})
function mockFetchStatus(status: number, body = '') {
function mockFetchStatus(status: number, body = '', headers?: HeadersInit) {
global.fetch = vi.fn(async () =>
new Response(body, { status, statusText: String(status) })
new Response(body, { status, statusText: String(status), headers })
) as unknown as typeof fetch
}
describe('skvRequest — error mapping', () => {
it('maps 401 → SESSION_EXPIRED', async () => {
it('maps empty 401 → ACCESS_DENIED (likely missing APIGW subscription)', async () => {
mockFetchStatus(401)
await expect(
skvRequest(fakeSupabase, 'user-1', 'GET', '/x'),
).rejects.toMatchObject({
name: 'SkatteverketAuthError',
code: 'SESSION_EXPIRED',
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
expect((e as SkatteverketAuthError).message).toMatch(/Utvecklarportalen|prenumeration/i)
}
})
it('maps 401 with body text → SESSION_EXPIRED and includes body', async () => {
mockFetchStatus(401, 'token expired')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
expect((e as SkatteverketAuthError).message).toContain('token expired')
}
})
it('maps 401 with WWW-Authenticate insufficient_scope → MISSING_SCOPE', async () => {
mockFetchStatus(401, '', {
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
})
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
}
})
it('maps 403 with Behörighet body → BEHORIGHET_SAKNAS', async () => {
@@ -213,9 +213,55 @@ export async function skvRequest(
// 1. Genuine token expiry / invalid bearer (user must re-auth)
// 2. APIGW client lacks subscription for this API (developer portal fix)
// — the bearer is valid but the gateway rejects the call.
// Read the body so we can distinguish and surface a useful message.
// Read the body and gateway-side headers so we can distinguish and
// surface a useful message.
const text = await response.text().catch(() => '')
console.error('[skatteverket] 401 from API', { url, body: text })
// WWW-Authenticate carries OAuth's machine-readable failure reason
// (insufficient_scope / invalid_token). The x-skv-* / x-amzn-* / x-api-*
// families are gateway-side hints SKV's APIGW emits when it rejects the
// call before reaching the application — the body is often empty in
// that case so the headers are the only signal.
const wwwAuth = response.headers.get('WWW-Authenticate') ?? ''
const skvHeaders: Record<string, string> = {}
response.headers.forEach((v, k) => {
const lk = k.toLowerCase()
if (
lk === 'www-authenticate' ||
lk.startsWith('x-skv-') ||
lk.startsWith('x-amzn-') ||
lk.startsWith('x-api-')
) {
skvHeaders[k] = v
}
})
console.error('[skatteverket] 401 from API', { url, body: text, headers: skvHeaders })
// (A) Surface SKV's WWW-Authenticate verbatim — when the body is empty
// this header is usually the only diagnostic SKV gives us. Carry both
// header and body into every thrown message below.
const headerSuffix = Object.keys(skvHeaders).length > 0
? ` Headers: ${JSON.stringify(skvHeaders)}`
: ''
const bodySuffix = text ? ` Svar: ${text}` : ''
// OAuth's standard insufficient_scope marker. SKV sometimes emits this
// as 401 (rather than 403) when the AGI APIGW evaluates scope before
// the application sees the token. The remedy is the same as MISSING_SCOPE:
// disconnect + reconnect to mint a token covering the AGI scope.
const wwwLower = wwwAuth.toLowerCase()
if (
wwwLower.includes('insufficient_scope') ||
wwwLower.includes('invalid_scope')
) {
throw new SkatteverketAuthError(
'Anslutningen mot Skatteverket saknar nödvändig behörighet för denna ' +
'tjänst. Koppla bort och anslut igen via Inställningar → Skatteverket ' +
'för att förnya tokenen med rätt scope.' +
headerSuffix + bodySuffix,
'MISSING_SCOPE'
)
}
// APIGW subscription / client-credential problems: the gateway responds
// before the bearer is ever evaluated. The user reconnecting won't help
@@ -233,15 +279,47 @@ export async function skvRequest(
throw new SkatteverketAuthError(
'Skatteverkets API-gateway nekade anropet. Kontrollera att din ' +
'APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har prenumeration på ' +
`denna tjänst i Utvecklarportalen. Svar från Skatteverket: ${text || '(tomt svar)'}`,
'denna tjänst i Utvecklarportalen.' +
headerSuffix +
` Svar från Skatteverket: ${text || '(tomt svar)'}`,
'ACCESS_DENIED'
)
}
// (B) Empty 401 with no diagnostic header → almost always a gateway/
// subscription issue rather than a real session expiry. We refreshed
// the local bearer immediately above, so an empty body with no
// WWW-Authenticate means SKV's APIGW rejected the call before it
// reached the application — typically because the APIGW client isn't
// subscribed to the API at the URL we just hit. Telling the user to
// "log in again" sends them down a dead end; be explicit about the
// likely fix instead.
if (!text) {
// Extract the API segment of the URL so the message tells the user
// exactly which subscription is missing. Falls back to the raw URL
// if parsing fails.
let apiHint = url
try {
const u = new URL(url)
const parts = u.pathname.split('/').filter(Boolean)
// Take the first 3 segments — e.g. arbetsgivardeklaration/inlamning/v1
if (parts.length >= 1) apiHint = parts.slice(0, 3).join('/')
} catch {
// keep raw url
}
throw new SkatteverketAuthError(
'Skatteverkets API-gateway nekade anropet utan motivering. ' +
'Trolig orsak: APIGW-klienten (SKATTEVERKET_APIGW_CLIENT_ID) har ' +
`inte prenumeration på tjänsten "${apiHint}" i Utvecklarportalen, ` +
'eller den lagrade tokenen saknar rätt scope. Kontrollera ' +
'prenumerationen, koppla annars bort och anslut igen via ' +
'Inställningar → Skatteverket.' + headerSuffix,
'ACCESS_DENIED'
)
}
throw new SkatteverketAuthError(
text
? `Sessionen har gått ut. Logga in med BankID igen. (Skatteverket: ${text})`
: 'Sessionen har gått ut. Logga in med BankID igen.',
`Sessionen har gått ut. Logga in med BankID igen.${headerSuffix}${bodySuffix}`,
'SESSION_EXPIRED'
)
}
+1 -1
View File
@@ -22,7 +22,7 @@ const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1
// section 4.1.2.2 — the 403 "Felaktigt access scope" example shows
// `"description": "The required scope agd has been requested for that access token."`
// The other tokens match the path segments of their respective APIs.
const DEFAULT_SCOPES = 'momsdeklaration inkforetag ska skahmst skattekonto agd'
const DEFAULT_SCOPES = 'momsdeklaration inkforetag skattekonto agd'
function getOAuthBaseUrl(): string {
return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL
@@ -67,7 +67,7 @@ describe('generate_invoice_number RPC', () => {
)
const assigned = rows[0]!.generate_invoice_number
expect(assigned).toMatch(/^F\d{4}\d{3}$/)
expect(assigned).toBe('F001')
const persisted = await getPool().query<{ invoice_number: string }>(
'SELECT invoice_number FROM public.invoices WHERE id = $1',
@@ -86,7 +86,7 @@ describe('generate_invoice_number RPC', () => {
[companyId, invoiceId, 'proforma'],
)
expect(rows[0]!.generate_invoice_number).toMatch(/^PF-\d{4}042$/)
expect(rows[0]!.generate_invoice_number).toBe('PF-042')
})
it('is idempotent: a second call on the same invoice returns the same number without bumping the counter', async () => {
@@ -167,8 +167,39 @@ describe('generate_invoice_number RPC', () => {
[companyId, invoiceB, 'invoice'],
)
expect(a.rows[0]!.generate_invoice_number).toMatch(/200$/)
expect(b.rows[0]!.generate_invoice_number).toMatch(/201$/)
expect(a.rows[0]!.generate_invoice_number).toBe('F200')
expect(b.rows[0]!.generate_invoice_number).toBe('F201')
})
it('uses bare number when invoice_prefix is null (no implicit year prefix)', async () => {
const { userId, companyId } = await seedCompany()
// Mirror the C by Sea bug report: user set next_invoice_number=10159 with
// no prefix, expected '10159', got '2026101' under the old year-prefix
// bug. After the fix the bare number is what they get.
await ensureCompanySettings({ userId, companyId, invoicePrefix: undefined, nextInvoiceNumber: 10159 })
await getPool().query(
'UPDATE public.company_settings SET invoice_prefix = NULL WHERE company_id = $1',
[companyId],
)
const invoiceId = await insertDraftInvoice({ userId, companyId })
const { rows } = await getPool().query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceId, 'invoice'],
)
expect(rows[0]!.generate_invoice_number).toBe('10159')
})
it('does not zero-pad numbers that exceed three digits', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, invoicePrefix: 'F-', nextInvoiceNumber: 10159 })
const invoiceId = await insertDraftInvoice({ userId, companyId })
const { rows } = await getPool().query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceId, 'invoice'],
)
expect(rows[0]!.generate_invoice_number).toBe('F-10159')
})
it('raises when the invoice id does not belong to the company', async () => {
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { getDisplayTotal } from '@/lib/invoices/rounding'
const inv = (total: number, currency: 'SEK' | 'EUR' = 'SEK') => ({ total, currency })
const co = (ore_rounding: boolean) => ({ ore_rounding })
describe('getDisplayTotal', () => {
it('rounds SEK with rounding enabled and a non-integer total', () => {
const r = getDisplayTotal(inv(1234.56), co(true))
expect(r.applies).toBe(true)
expect(r.displayed).toBe(1235)
expect(r.roundingDelta).toBe(0.44)
})
it('rounds down when fractional part < 0.5', () => {
const r = getDisplayTotal(inv(1234.4), co(true))
expect(r.applies).toBe(true)
expect(r.displayed).toBe(1234)
expect(r.roundingDelta).toBe(-0.4)
})
it('does not apply when setting is disabled', () => {
const r = getDisplayTotal(inv(1234.56), co(false))
expect(r.applies).toBe(false)
expect(r.displayed).toBe(1234.56)
expect(r.roundingDelta).toBe(0)
})
it('does not apply for non-SEK currencies', () => {
const r = getDisplayTotal(inv(1234.56, 'EUR'), co(true))
expect(r.applies).toBe(false)
expect(r.displayed).toBe(1234.56)
})
it('does not apply when total is already an integer', () => {
const r = getDisplayTotal(inv(1235), co(true))
expect(r.applies).toBe(false)
expect(r.displayed).toBe(1235)
expect(r.roundingDelta).toBe(0)
})
it('treats missing company settings as default-on', () => {
const r = getDisplayTotal(inv(99.99), null)
expect(r.applies).toBe(true)
expect(r.displayed).toBe(100)
})
})
+18 -15
View File
@@ -8,6 +8,7 @@ import {
} from '@react-pdf/renderer'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { generateOcrReference } from '@/lib/bankgiro/luhn'
import { getDisplayTotal } from '@/lib/invoices/rounding'
// Create styles
const styles = StyleSheet.create({
@@ -542,21 +543,23 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
</View>
)}
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
<Text style={styles.grandTotalValue}>{formatCurrency(
(company.ore_rounding ?? true) && invoice.currency === 'SEK'
? Math.round(invoice.total)
: invoice.total,
invoice.currency
)}</Text>
</View>
{(company.ore_rounding ?? true) && invoice.currency === 'SEK' && Math.round(invoice.total) !== invoice.total && (
<View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(Math.round(invoice.total) - invoice.total, 'SEK')}</Text>
</View>
)}
{(() => {
const rounding = getDisplayTotal(invoice, company)
return (
<>
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
<Text style={styles.grandTotalValue}>{formatCurrency(rounding.displayed, invoice.currency)}</Text>
</View>
{rounding.applies && (
<View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(rounding.roundingDelta, 'SEK')}</Text>
</View>
)}
</>
)
})()}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<View style={{ marginTop: 8 }}>
{invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && (
+39
View File
@@ -0,0 +1,39 @@
import type { Invoice, CompanySettings } from '@/types'
type InvoiceTotalShape = Pick<Invoice, 'total' | 'currency'>
type CompanyRoundingShape = Pick<CompanySettings, 'ore_rounding'>
export interface DisplayTotal {
/** Total to render to the user (rounded if öresavrundning applies, raw otherwise). */
displayed: number
/** displayed - raw total. Zero when rounding does not apply or the total is already an integer. */
roundingDelta: number
/** True when both the company setting is on, currency is SEK, and there are öre to round. */
applies: boolean
}
/**
* Single source of truth for öresavrundning display logic. Mirrors the rule
* baked into the PDF template since day one: only SEK invoices, only when
* the company has the setting enabled, and only when there's actually a
* non-integer total to round. The helper centralizes the rule so the list,
* detail page, and PDF cannot drift apart.
*/
export function getDisplayTotal(
invoice: InvoiceTotalShape,
company: CompanyRoundingShape | null | undefined,
): DisplayTotal {
const enabled = company?.ore_rounding ?? true
if (!enabled || invoice.currency !== 'SEK') {
return { displayed: invoice.total, roundingDelta: 0, applies: false }
}
const rounded = Math.round(invoice.total)
if (rounded === invoice.total) {
return { displayed: invoice.total, roundingDelta: 0, applies: false }
}
return {
displayed: rounded,
roundingDelta: Math.round((rounded - invoice.total) * 100) / 100,
applies: true,
}
}
@@ -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';