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:
@@ -12,6 +12,7 @@ import { useToast } from '@/components/ui/use-toast'
|
|||||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||||||
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||||
import { invoiceNumberDisplay } from '@/lib/invoices/display'
|
import { invoiceNumberDisplay } from '@/lib/invoices/display'
|
||||||
|
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -85,6 +86,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
|||||||
const [isDownloading, setIsDownloading] = useState(false)
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [oreRounding, setOreRounding] = useState<boolean>(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchInvoice()
|
fetchInvoice()
|
||||||
@@ -120,6 +122,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
|||||||
|
|
||||||
setInvoice(data as InvoiceWithRelations)
|
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
|
// Fetch reminders for this invoice
|
||||||
const { data: reminderData } = await supabase
|
const { data: reminderData } = await supabase
|
||||||
.from('invoice_reminders')
|
.from('invoice_reminders')
|
||||||
@@ -579,10 +591,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
|||||||
))
|
))
|
||||||
})()}
|
})()}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between font-bold text-lg">
|
{(() => {
|
||||||
<span>Totalt</span>
|
const rounding = getDisplayTotal(invoice, { ore_rounding: oreRounding })
|
||||||
<span>{formatCurrency(invoice.total, invoice.currency)}</span>
|
return (
|
||||||
</div>
|
<>
|
||||||
|
{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 && (
|
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||||
<div className="flex justify-between text-sm text-muted-foreground">
|
<div className="flex justify-between text-sm text-muted-foreground">
|
||||||
<span>I SEK (kurs {invoice.exchange_rate})</span>
|
<span>I SEK (kurs {invoice.exchange_rate})</span>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default function NewInvoicePage() {
|
|||||||
const [hasBankDetails, setHasBankDetails] = useState<boolean | null>(null)
|
const [hasBankDetails, setHasBankDetails] = useState<boolean | null>(null)
|
||||||
const [showBankSetup, setShowBankSetup] = useState(false)
|
const [showBankSetup, setShowBankSetup] = useState(false)
|
||||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||||
|
const [numberPreview, setNumberPreview] = useState<string | null>(null)
|
||||||
const pendingCustomerRef = useRef<Customer | null>(null)
|
const pendingCustomerRef = useRef<Customer | null>(null)
|
||||||
|
|
||||||
const {
|
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(() => {
|
useEffect(() => {
|
||||||
if (watchCustomerId) {
|
if (watchCustomerId) {
|
||||||
const customer = customers.find((c) => c.id === watchCustomerId)
|
const customer = customers.find((c) => c.id === watchCustomerId)
|
||||||
@@ -257,8 +281,21 @@ export default function NewInvoicePage() {
|
|||||||
}
|
}
|
||||||
const total = subtotal + vatAmount
|
const total = subtotal + vatAmount
|
||||||
|
|
||||||
function onSubmit(data: FormData) {
|
async function onSubmit(data: FormData) {
|
||||||
setPendingData(data)
|
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') {
|
if (hasBankDetails === false && watchDocumentType === 'invoice') {
|
||||||
setShowBankSetup(true)
|
setShowBankSetup(true)
|
||||||
return
|
return
|
||||||
@@ -405,6 +442,11 @@ export default function NewInvoicePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
<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'}
|
{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>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<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'}
|
{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}
|
yourReference={pendingData?.your_reference}
|
||||||
ourReference={pendingData?.our_reference}
|
ourReference={pendingData?.our_reference}
|
||||||
notes={pendingData?.notes}
|
notes={pendingData?.notes}
|
||||||
|
numberPreview={numberPreview}
|
||||||
/>
|
/>
|
||||||
</ConfirmationDialog>
|
</ConfirmationDialog>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useToast } from '@/components/ui/use-toast'
|
|||||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { invoiceNumberDisplay } from '@/lib/invoices/display'
|
import { invoiceNumberDisplay } from '@/lib/invoices/display'
|
||||||
|
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||||
import { Plus, Search, Receipt, Lock } from 'lucide-react'
|
import { Plus, Search, Receipt, Lock } from 'lucide-react'
|
||||||
import { EmptyInvoices } from '@/components/ui/empty-state'
|
import { EmptyInvoices } from '@/components/ui/empty-state'
|
||||||
import { useCompany } from '@/contexts/CompanyContext'
|
import { useCompany } from '@/contexts/CompanyContext'
|
||||||
@@ -55,6 +56,7 @@ export default function InvoicesPage() {
|
|||||||
const { company } = useCompany()
|
const { company } = useCompany()
|
||||||
const { canWrite } = useCanWrite()
|
const { canWrite } = useCanWrite()
|
||||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||||
|
const [oreRounding, setOreRounding] = useState<boolean>(true)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
@@ -64,21 +66,29 @@ export default function InvoicesPage() {
|
|||||||
async function fetchInvoices() {
|
async function fetchInvoices() {
|
||||||
if (!company) return
|
if (!company) return
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const { data, error } = await supabase
|
const [invoicesResult, settingsResult] = await Promise.all([
|
||||||
.from('invoices')
|
supabase
|
||||||
.select('*, customer:customers(name)')
|
.from('invoices')
|
||||||
.eq('company_id', company.id)
|
.select('*, customer:customers(name)')
|
||||||
.order('invoice_date', { ascending: false })
|
.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({
|
toast({
|
||||||
title: 'Kunde inte ladda fakturor',
|
title: 'Kunde inte ladda fakturor',
|
||||||
description: 'Kontrollera din anslutning och försök igen.',
|
description: 'Kontrollera din anslutning och försök igen.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setInvoices(data || [])
|
setInvoices(invoicesResult.data || [])
|
||||||
}
|
}
|
||||||
|
setOreRounding(settingsResult.data?.ore_rounding ?? true)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +303,10 @@ export default function InvoicesPage() {
|
|||||||
<div className="flex items-start sm:items-center justify-between gap-2">
|
<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={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' : ''}`}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/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
|
* 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 })
|
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
|
// Idempotency: if already seeded, return early
|
||||||
const { data: existing } = await supabase
|
const { data: existing } = await supabase
|
||||||
@@ -523,7 +549,8 @@ export async function POST() {
|
|||||||
if (dlError) throw dlError
|
if (dlError) throw dlError
|
||||||
|
|
||||||
return NextResponse.json({ seeded: true })
|
return NextResponse.json({ seeded: true })
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
log.error('failed to seed sandbox data', { error: err, userId: user.id, companyId })
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to seed sandbox data' },
|
{ error: 'Failed to seed sandbox data' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { switchCompany } from '@/lib/company/actions'
|
|||||||
import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
|
import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
export default function CompanySwitcher() {
|
export default function CompanySwitcher() {
|
||||||
const { company, companies } = useCompany()
|
const { company, companies, isSandbox } = useCompany()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
@@ -106,8 +106,9 @@ export default function CompanySwitcher() {
|
|||||||
const hasMultiple = companies.length > 1
|
const hasMultiple = companies.length > 1
|
||||||
|
|
||||||
// No companies yet — show a direct "Lägg till företag" link instead of
|
// 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 (!company && companies.length === 0) {
|
||||||
|
if (isSandbox) return null
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/select-company"
|
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')}>
|
{!isSandbox && (
|
||||||
<Link
|
<div className={cn(companies.length > 0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
|
||||||
href="/select-company"
|
<Link
|
||||||
onClick={() => setOpen(false)}
|
href="/select-company"
|
||||||
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"
|
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
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Link>
|
Lägg till företag
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ interface InvoiceReviewContentProps {
|
|||||||
yourReference?: string
|
yourReference?: string
|
||||||
ourReference?: string
|
ourReference?: string
|
||||||
notes?: 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({
|
export function InvoiceReviewContent({
|
||||||
@@ -39,6 +42,7 @@ export function InvoiceReviewContent({
|
|||||||
yourReference,
|
yourReference,
|
||||||
ourReference,
|
ourReference,
|
||||||
notes,
|
notes,
|
||||||
|
numberPreview,
|
||||||
}: InvoiceReviewContentProps) {
|
}: InvoiceReviewContentProps) {
|
||||||
const customerTypeLabel: Record<string, string> = {
|
const customerTypeLabel: Record<string, string> = {
|
||||||
individual: 'Privatperson',
|
individual: 'Privatperson',
|
||||||
@@ -60,6 +64,12 @@ export function InvoiceReviewContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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 */}
|
{/* 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="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">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -734,55 +734,61 @@ export function AGIPanel(props: AGIPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!readOnly && !isSigned && (
|
{!readOnly && !isSigned && (
|
||||||
<>
|
<div className="flex flex-wrap gap-2">
|
||||||
{/* Direct AGI submission to Skatteverket is paused while the
|
<Button
|
||||||
APIGW subscription is sorted out at SKV's end. Users still
|
size="sm"
|
||||||
generate and download the AGI XML from the salary run page
|
variant="outline"
|
||||||
and upload it manually via Mina Sidor. Re-enable the three
|
onClick={handleSubmit}
|
||||||
buttons below once the subscription is in place. */}
|
disabled={actionLoading === 'submit'}
|
||||||
<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">
|
{actionLoading === 'submit' ? (
|
||||||
Direktinlämning till Skatteverket är pausad
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Skicka in underlag
|
)}
|
||||||
</Button>
|
Skicka in underlag
|
||||||
<Button
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
onClick={handleCreateSigningLink}
|
size="sm"
|
||||||
disabled
|
onClick={handleCreateSigningLink}
|
||||||
title="Direktinlämning till Skatteverket är pausad"
|
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" />
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleCheckSubmitted}
|
onClick={handleUnlock}
|
||||||
disabled
|
disabled={actionLoading === 'unlock'}
|
||||||
title="Direktinlämning till Skatteverket är pausad"
|
|
||||||
>
|
>
|
||||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
{actionLoading === 'unlock' ? (
|
||||||
Hämta kvittens
|
<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>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ type Status =
|
|||||||
const SCOPE_LABELS: Record<string, string> = {
|
const SCOPE_LABELS: Record<string, string> = {
|
||||||
momsdeklaration: 'Momsdeklaration',
|
momsdeklaration: 'Momsdeklaration',
|
||||||
inkforetag: 'Företagsinformation',
|
inkforetag: 'Företagsinformation',
|
||||||
ska: 'Skatteinformation',
|
|
||||||
skahmst: 'Hemortskommun',
|
|
||||||
skattekonto: 'Skattekonto',
|
skattekonto: 'Skattekonto',
|
||||||
agd: 'Arbetsgivardeklaration',
|
agd: 'Arbetsgivardeklaration',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,21 +35,48 @@ beforeEach(() => {
|
|||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
function mockFetchStatus(status: number, body = '') {
|
function mockFetchStatus(status: number, body = '', headers?: HeadersInit) {
|
||||||
global.fetch = vi.fn(async () =>
|
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
|
) as unknown as typeof fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('skvRequest — error mapping', () => {
|
describe('skvRequest — error mapping', () => {
|
||||||
it('maps 401 → SESSION_EXPIRED', async () => {
|
it('maps empty 401 → ACCESS_DENIED (likely missing APIGW subscription)', async () => {
|
||||||
mockFetchStatus(401)
|
mockFetchStatus(401)
|
||||||
await expect(
|
try {
|
||||||
skvRequest(fakeSupabase, 'user-1', 'GET', '/x'),
|
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||||
).rejects.toMatchObject({
|
expect.fail('expected throw')
|
||||||
name: 'SkatteverketAuthError',
|
} catch (e) {
|
||||||
code: 'SESSION_EXPIRED',
|
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 () => {
|
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)
|
// 1. Genuine token expiry / invalid bearer (user must re-auth)
|
||||||
// 2. APIGW client lacks subscription for this API (developer portal fix)
|
// 2. APIGW client lacks subscription for this API (developer portal fix)
|
||||||
// — the bearer is valid but the gateway rejects the call.
|
// — 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(() => '')
|
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
|
// APIGW subscription / client-credential problems: the gateway responds
|
||||||
// before the bearer is ever evaluated. The user reconnecting won't help
|
// before the bearer is ever evaluated. The user reconnecting won't help
|
||||||
@@ -233,15 +279,47 @@ export async function skvRequest(
|
|||||||
throw new SkatteverketAuthError(
|
throw new SkatteverketAuthError(
|
||||||
'Skatteverkets API-gateway nekade anropet. Kontrollera att din ' +
|
'Skatteverkets API-gateway nekade anropet. Kontrollera att din ' +
|
||||||
'APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har prenumeration på ' +
|
'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'
|
'ACCESS_DENIED'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new SkatteverketAuthError(
|
throw new SkatteverketAuthError(
|
||||||
text
|
`Sessionen har gått ut. Logga in med BankID igen.${headerSuffix}${bodySuffix}`,
|
||||||
? `Sessionen har gått ut. Logga in med BankID igen. (Skatteverket: ${text})`
|
|
||||||
: 'Sessionen har gått ut. Logga in med BankID igen.',
|
|
||||||
'SESSION_EXPIRED'
|
'SESSION_EXPIRED'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// section 4.1.2.2 — the 403 "Felaktigt access scope" example shows
|
||||||
// `"description": "The required scope agd has been requested for that access token."`
|
// `"description": "The required scope agd has been requested for that access token."`
|
||||||
// The other tokens match the path segments of their respective APIs.
|
// 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 {
|
function getOAuthBaseUrl(): string {
|
||||||
return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL
|
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
|
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 }>(
|
const persisted = await getPool().query<{ invoice_number: string }>(
|
||||||
'SELECT invoice_number FROM public.invoices WHERE id = $1',
|
'SELECT invoice_number FROM public.invoices WHERE id = $1',
|
||||||
@@ -86,7 +86,7 @@ describe('generate_invoice_number RPC', () => {
|
|||||||
[companyId, invoiceId, 'proforma'],
|
[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 () => {
|
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'],
|
[companyId, invoiceB, 'invoice'],
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(a.rows[0]!.generate_invoice_number).toMatch(/200$/)
|
expect(a.rows[0]!.generate_invoice_number).toBe('F200')
|
||||||
expect(b.rows[0]!.generate_invoice_number).toMatch(/201$/)
|
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 () => {
|
it('raises when the invoice id does not belong to the company', async () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@react-pdf/renderer'
|
} from '@react-pdf/renderer'
|
||||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||||
import { generateOcrReference } from '@/lib/bankgiro/luhn'
|
import { generateOcrReference } from '@/lib/bankgiro/luhn'
|
||||||
|
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||||
|
|
||||||
// Create styles
|
// Create styles
|
||||||
const styles = StyleSheet.create({
|
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>
|
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={styles.grandTotal}>
|
{(() => {
|
||||||
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
|
const rounding = getDisplayTotal(invoice, company)
|
||||||
<Text style={styles.grandTotalValue}>{formatCurrency(
|
return (
|
||||||
(company.ore_rounding ?? true) && invoice.currency === 'SEK'
|
<>
|
||||||
? Math.round(invoice.total)
|
<View style={styles.grandTotal}>
|
||||||
: invoice.total,
|
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
|
||||||
invoice.currency
|
<Text style={styles.grandTotalValue}>{formatCurrency(rounding.displayed, invoice.currency)}</Text>
|
||||||
)}</Text>
|
</View>
|
||||||
</View>
|
{rounding.applies && (
|
||||||
{(company.ore_rounding ?? true) && invoice.currency === 'SEK' && Math.round(invoice.total) !== invoice.total && (
|
<View style={styles.totalRow}>
|
||||||
<View style={styles.totalRow}>
|
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
|
||||||
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
|
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(rounding.roundingDelta, 'SEK')}</Text>
|
||||||
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(Math.round(invoice.total) - invoice.total, 'SEK')}</Text>
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||||
<View style={{ marginTop: 8 }}>
|
<View style={{ marginTop: 8 }}>
|
||||||
{invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && (
|
{invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && (
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user