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