Bug/skv konto numbers (#498)

* feat(skattekonto): add overdue transactions handling and split logic

* feat: enhance transaction handling and loading states

- Update BalanceHero component to display last synced date and additional information about Skatteverket updates.
- Refactor BookDirectlyDialog to simplify transaction linking logic and improve UI for transaction selection.
- Revamp InvoiceInboxWorkspace layout for better responsiveness and user experience, including improved skeleton loading states.
- Introduce new loading states for ExtensionWorkspace to match the live layout and improve user feedback during data fetching.
- Implement exchange rate fetching in QuickReviewDialog, ensuring transactions are always processed in SEK with error handling for rate fetching.
- Add structured error handling for unavailable exchange rates in the transaction API.

* feat(skattekonto): add 'Skattekonto – saldo & transaktioner' scope and update authorization checks

* feat: implement reverse charge handling in supplier invoice calculations and UI
This commit is contained in:
Mattsson
2026-05-15 16:25:05 +02:00
committed by GitHub
parent 3912c74a7b
commit 7175daee87
16 changed files with 780 additions and 623 deletions
@@ -0,0 +1,149 @@
import { headers } from 'next/headers'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardHeader, CardContent } from '@/components/ui/card'
import { PageHeader } from '@/components/ui/page-header'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
// Mirror of FULLSCREEN_WORKSPACES in ExtensionWorkspaceLoader. loading.tsx
// can't read route params, so we inspect the forwarded x-pathname header to
// branch the skeleton shape — the parent dashboard loading.tsx renders a
// metrics dashboard shape that has nothing to do with extension workspaces.
const FULLSCREEN_WORKSPACES = new Set(['general/invoice-inbox'])
export default async function ExtensionWorkspaceLoading() {
const h = await headers()
const pathname = h.get('x-pathname') ?? ''
const match = pathname.match(/^\/e\/([^/]+)\/([^/]+)/)
const sector = match?.[1] ?? ''
const slug = match?.[2] ?? ''
const key = `${sector}/${slug}`
if (FULLSCREEN_WORKSPACES.has(key)) {
return <FullScreenWorkspaceSkeleton />
}
const definition = sector && slug ? getExtensionDefinition(sector, slug) : undefined
return (
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10 space-y-8">
{definition ? (
<PageHeader title={definition.name} />
) : (
<Skeleton className="h-9 md:h-10 w-64" />
)}
<ShellWorkspaceBody workspaceKey={key} />
</div>
)
}
function ShellWorkspaceBody({ workspaceKey }: { workspaceKey: string }) {
if (workspaceKey === 'general/tic') {
return <TicSkeleton />
}
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-64 w-full rounded-lg" />
</div>
)
}
function TicSkeleton() {
return (
<div className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader className="space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-3.5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-2">
<Skeleton className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<Skeleton className="h-3.5 flex-1 max-w-[260px]" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 shrink-0" />
<Skeleton className="h-3.5 w-48" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 shrink-0" />
<Skeleton className="h-3.5 w-36" />
</div>
<div className="pt-2 border-t space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-44" />
</div>
<div className="pt-2 border-t space-y-1.5">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
<Skeleton className="h-3 w-32 mt-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3.5 w-44" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)
}
function FullScreenWorkspaceSkeleton() {
return (
<div className="h-[calc(100vh-1px)] p-4 md:p-6">
<div className="h-full flex flex-col rounded-lg border bg-card overflow-hidden">
<header className="flex items-center justify-between gap-4 border-b px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<Skeleton className="h-4 w-4 shrink-0" />
<Skeleton className="h-4 w-32 shrink-0" />
<Skeleton className="hidden md:block h-3 w-56" />
</div>
<Skeleton className="h-8 w-28 shrink-0" />
</header>
<div className="flex-1 grid grid-cols-1 md:grid-cols-[240px_minmax(0,1fr)_320px] lg:grid-cols-[280px_minmax(0,1fr)_340px] min-h-0">
<aside className="border-r overflow-hidden bg-muted/20 pt-3">
<div className="px-3 pb-3 space-y-2 border-b">
<Skeleton className="h-8 w-full" />
<div className="flex flex-wrap gap-1">
<Skeleton className="h-5 w-10 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
</div>
<ul>
{Array.from({ length: 7 }).map((_, i) => (
<li key={i} className="border-b px-3 py-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-3 shrink-0" />
<Skeleton className="h-3.5 flex-1 max-w-[180px]" />
</div>
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
</li>
))}
</ul>
</aside>
<main className="overflow-hidden bg-muted/10 hidden md:block" />
<aside className="border-l overflow-hidden hidden md:block" />
</div>
</div>
</div>
)
}
+26 -1
View File
@@ -40,6 +40,7 @@ interface SaldoEnvelope {
interface TransaktionerEnvelope {
data: {
booked: SkattekontoTransactionWithSuggestion[]
overdue: StoredSkattekontoTransaction[]
upcoming: StoredSkattekontoTransaction[]
}
}
@@ -267,6 +268,9 @@ export default function SkattekontoPage() {
<TabsTrigger value="booked">
Genomförda {tx?.booked ? `(${tx.booked.length})` : ''}
</TabsTrigger>
<TabsTrigger value="overdue">
Förfallna {tx?.overdue ? `(${tx.overdue.length})` : ''}
</TabsTrigger>
<TabsTrigger value="upcoming">
Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''}
</TabsTrigger>
@@ -280,6 +284,16 @@ export default function SkattekontoPage() {
emptyText="Inga genomförda transaktioner än."
/>
</TabsContent>
<TabsContent value="overdue" className="mt-4">
<TransactionTable
rows={tx?.overdue ?? []}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
emptyText="Inga förfallna transaktioner."
showForfallodatum
/>
</TabsContent>
<TabsContent value="upcoming" className="mt-4">
<TransactionTable
rows={tx?.upcoming ?? []}
@@ -420,7 +434,7 @@ function BalanceHero({
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Senast uppdaterad
Saldo per
</p>
<p className="font-medium tabular-nums">
{new Date(data.senastUppdaterad).toLocaleString('sv-SE')}
@@ -428,6 +442,17 @@ function BalanceHero({
</div>
</div>
{saldo.lastSyncedAt && (
<p className="text-xs text-muted-foreground">
Senast synkad{' '}
<span className="tabular-nums">
{new Date(saldo.lastSyncedAt).toLocaleString('sv-SE')}
</span>
. Skatteverket uppdaterar saldot periodvis — datumet ovan ändras
inte varje gång du synkroniserar.
</p>
)}
{data.informationstext.length > 0 && (
<div className="rounded-md border bg-muted/30 p-3">
<p className="mb-1 text-xs font-medium uppercase tracking-wide">
@@ -168,6 +168,7 @@ export default function NewSupplierInvoicePage() {
const watchedSupplierId = watch('supplier_id')
const watchedCurrency = watch('currency')
const watchedPaidPrivately = watch('paid_with_private_funds')
const watchedReverseCharge = watch('reverse_charge')
const isEF = entityType === 'enskild_firma'
@@ -401,7 +402,11 @@ export default function NewSupplierInvoicePage() {
})
const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0)
const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0)
const total = Math.round((subtotal + totalVat) * 100) / 100
// Reverse charge: supplier never invoices VAT, so it doesn't roll into the
// payable total. The VAT is still accounted for via 2614 / 2645 in
// bookkeeping — the line stays in the breakdown for transparency.
const payableVat = watchedReverseCharge ? 0 : totalVat
const total = Math.round((subtotal + payableVat) * 100) / 100
// Show the AI-suggested supplier card when we have an inbox item, the AI
// surfaced a supplier name, and we couldn't match it to an existing record.
@@ -1251,7 +1256,9 @@ export default function NewSupplierInvoicePage() {
<span className="font-mono sm:w-32 text-right">{formatCurrency(subtotal, watchedCurrency)}</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">Moms</span>
<span className="text-muted-foreground">
{watchedReverseCharge ? 'Moms (omvänd, redovisas av köparen)' : 'Moms'}
</span>
<span className="font-mono sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8 font-bold text-lg">
+5 -1
View File
@@ -132,7 +132,11 @@ export const POST = withRouteContext(
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
const total = Math.round((subtotal + vatAmount) * 100) / 100
// Reverse charge: supplier never invoices VAT, so the payable total equals
// the net. VAT is still tracked separately (vat_amount) for declarations
// and books fiktiv 2614/2645 in the engine, but neither side moves cash.
const payableVat = body.reverse_charge ? 0 : vatAmount
const total = Math.round((subtotal + payableVat) * 100) / 100
// Representation (BAS 6070–6079): ingående moms is only deductible up to
// 300 SEK base/person per ML 8 kap. 1 §, and the income-tax deduction was
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import type { Currency, Transaction } from '@/types'
export const POST = withRouteContext(
'transaction.refreshExchangeRate',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single<Transaction>()
if (fetchError || !transaction) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
}
// No-op for SEK transactions, or when the rate is already cached.
if (
transaction.currency === 'SEK' ||
(transaction.amount_sek != null && transaction.exchange_rate != null)
) {
return NextResponse.json({ data: transaction })
}
const rate = await fetchExchangeRate(transaction.currency as Currency, new Date(transaction.date))
if (!rate) {
return errorResponseFromCode('TX_EXCHANGE_RATE_UNAVAILABLE', log, {
requestId,
details: { currency: transaction.currency, date: transaction.date },
})
}
const amountSek = Math.round(transaction.amount * rate.rate * 100) / 100
const { data: updated, error: updateError } = await supabase
.from('transactions')
.update({
amount_sek: amountSek,
exchange_rate: rate.rate,
exchange_rate_date: rate.date,
})
.eq('id', id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.select('*')
.single<Transaction>()
if (updateError || !updated) {
return errorResponse(updateError ?? new Error('Failed to persist exchange rate'), log, {
requestId,
})
}
return NextResponse.json({ data: updated })
},
{ requireWrite: true },
)