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 { 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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 { 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 }
|
||||
|
||||
Reference in New Issue
Block a user