Merge pull request #2 from erp-mafia/fixing-extensions-bug
Fixing extensions bug
This commit is contained in:
@@ -21,7 +21,9 @@
|
||||
"Bash(npx supabase:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(findstr:*)"
|
||||
"Bash(findstr:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,10 +512,33 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
<span className="text-muted-foreground">Delsumma</span>
|
||||
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms ({invoice.vat_rate}%)</span>
|
||||
<span>{formatCurrency(invoice.vat_amount, invoice.currency)}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const vatByRate = new Map<number, number>()
|
||||
for (const item of invoice.items) {
|
||||
const rate = item.vat_rate ?? 25
|
||||
const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
|
||||
}
|
||||
const entries = Array.from(vatByRate.entries())
|
||||
.filter(([, vat]) => vat > 0)
|
||||
.sort(([a], [b]) => b - a)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{formatCurrency(0, invoice.currency)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return entries.map(([rate, vat]) => (
|
||||
<div key={rate} className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms {rate}%</span>
|
||||
<span>{formatCurrency(vat, invoice.currency)}</span>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
<Separator />
|
||||
<div className="flex justify-between font-bold text-lg">
|
||||
<span>Totalt</span>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getVatRules, getVatTreatmentLabel, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { getVatRules, getAvailableVatRates, getVatSummaryFromItems } from '@/lib/invoices/vat-rules'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
@@ -369,7 +369,7 @@ export default function NewInvoicePage() {
|
||||
{selectedCustomer && vatRules && (
|
||||
<div className="mt-4 p-3 bg-muted rounded-lg">
|
||||
<p className="text-sm">
|
||||
<strong>Momsbehandling:</strong> {getVatTreatmentLabel(vatRules.treatment)}
|
||||
<strong>Momsbehandling:</strong> {getVatSummaryFromItems(watchItems).label}
|
||||
</p>
|
||||
{vatRules.reverseChargeText && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
@@ -664,10 +664,8 @@ export default function NewInvoicePage() {
|
||||
vat_rate: item.vat_rate ?? (vatRules?.rate || 25),
|
||||
}))}
|
||||
subtotal={subtotal}
|
||||
vatRate={vatRules.rate}
|
||||
vatAmount={vatAmount}
|
||||
total={total}
|
||||
vatTreatment={vatRules.treatment}
|
||||
yourReference={pendingData?.your_reference}
|
||||
ourReference={pendingData?.our_reference}
|
||||
notes={pendingData?.notes}
|
||||
|
||||
@@ -103,9 +103,8 @@ export default function ReportsPage() {
|
||||
|
||||
{selectedPeriod ? (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="relative">
|
||||
<TabsList className="w-full justify-start overflow-x-auto flex-nowrap scrollbar-hide">
|
||||
<TabsTrigger value="trial-balance">
|
||||
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 p-1">
|
||||
<TabsTrigger value="trial-balance">
|
||||
<Scale className="h-4 w-4 mr-1" />
|
||||
Saldobalans
|
||||
</TabsTrigger>
|
||||
@@ -151,9 +150,7 @@ export default function ReportsPage() {
|
||||
<ArrowLeftRight className="h-4 w-4 mr-1" />
|
||||
Bankavstämning
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none md:hidden" />
|
||||
</div>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="trial-balance">
|
||||
<TrialBalanceView periodId={selectedPeriod} />
|
||||
|
||||
@@ -17,7 +17,7 @@ import TransactionInboxCard from '@/components/transactions/TransactionInboxCard
|
||||
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
|
||||
import InboxZeroState from '@/components/transactions/InboxZeroState'
|
||||
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
|
||||
import CategoryExpandedDialog from '@/components/transactions/CategoryExpandedDialog'
|
||||
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
|
||||
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
|
||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types'
|
||||
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
@@ -44,10 +44,9 @@ export default function TransactionsPage() {
|
||||
const [selectedTransaction, setSelectedTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
const [isConfirmingMatch, setIsConfirmingMatch] = useState(false)
|
||||
|
||||
// Category expanded dialog
|
||||
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false)
|
||||
const [categoryDialogTransaction, setCategoryDialogTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
const [categoryDialogProcessing, setCategoryDialogProcessing] = useState(false)
|
||||
// Booking dialog (journal entry form)
|
||||
const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
|
||||
const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
|
||||
// Set of transaction IDs that are animating out (just categorized)
|
||||
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
|
||||
@@ -164,7 +163,16 @@ export default function TransactionsPage() {
|
||||
// Mark as exiting for animation, then update state
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
|
||||
if (result.journal_entry_created) {
|
||||
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
|
||||
} else if (result.journal_entry_error) {
|
||||
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' })
|
||||
}
|
||||
|
||||
// Update transaction in state after a brief delay for animation
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
setTimeout(() => {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
@@ -178,17 +186,9 @@ export default function TransactionsPage() {
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
setProcessingId(null)
|
||||
}, 350)
|
||||
|
||||
if (result.journal_entry_created) {
|
||||
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
|
||||
} else if (result.journal_entry_error) {
|
||||
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' })
|
||||
}
|
||||
|
||||
setProcessingId(null)
|
||||
return true
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Något gick fel vid bokföring', variant: 'destructive' })
|
||||
@@ -218,6 +218,12 @@ export default function TransactionsPage() {
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Faktura matchad',
|
||||
description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`,
|
||||
})
|
||||
setMatchDialogOpen(false)
|
||||
|
||||
// Mark as exiting for animation
|
||||
setExitingIds((prev) => new Set(prev).add(selectedTransaction.id))
|
||||
setTimeout(() => {
|
||||
@@ -241,18 +247,13 @@ export default function TransactionsPage() {
|
||||
next.delete(selectedTransaction.id)
|
||||
return next
|
||||
})
|
||||
setSelectedTransaction(null)
|
||||
setIsConfirmingMatch(false)
|
||||
}, 350)
|
||||
|
||||
toast({
|
||||
title: 'Faktura matchad',
|
||||
description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`,
|
||||
})
|
||||
setMatchDialogOpen(false)
|
||||
setSelectedTransaction(null)
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' })
|
||||
setIsConfirmingMatch(false)
|
||||
}
|
||||
setIsConfirmingMatch(false)
|
||||
}
|
||||
|
||||
async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise<boolean> {
|
||||
@@ -329,15 +330,25 @@ export default function TransactionsPage() {
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
async function handleCategoryDialogSelect(category: TransactionCategory) {
|
||||
if (!categoryDialogTransaction) return
|
||||
setCategoryDialogProcessing(true)
|
||||
const success = await handleCategorize(categoryDialogTransaction.id, true, category)
|
||||
setCategoryDialogProcessing(false)
|
||||
if (success) {
|
||||
setCategoryDialogOpen(false)
|
||||
setCategoryDialogTransaction(null)
|
||||
}
|
||||
function handleTransactionBooked(transactionId: string, journalEntryId: string) {
|
||||
setExitingIds((prev) => new Set(prev).add(transactionId))
|
||||
setTimeout(() => {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transactionId
|
||||
? { ...t, is_business: true, journal_entry_id: journalEntryId }
|
||||
: t
|
||||
)
|
||||
)
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(transactionId)
|
||||
return next
|
||||
})
|
||||
}, 350)
|
||||
setBookingDialogOpen(false)
|
||||
setBookingDialogTransaction(null)
|
||||
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
|
||||
}
|
||||
|
||||
// Batch mode handlers
|
||||
@@ -401,8 +412,8 @@ export default function TransactionsPage() {
|
||||
}
|
||||
|
||||
function openCategoryDialog(transaction: TransactionWithInvoice) {
|
||||
setCategoryDialogTransaction(transaction)
|
||||
setCategoryDialogOpen(true)
|
||||
setBookingDialogTransaction(transaction)
|
||||
setBookingDialogOpen(true)
|
||||
}
|
||||
|
||||
// Swipe view
|
||||
@@ -481,6 +492,7 @@ export default function TransactionsPage() {
|
||||
<TransactionHistoryList
|
||||
transactions={transactions}
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -518,12 +530,11 @@ export default function TransactionsPage() {
|
||||
onConfirm={handleConfirmInvoiceMatch}
|
||||
/>
|
||||
|
||||
<CategoryExpandedDialog
|
||||
open={categoryDialogOpen}
|
||||
onOpenChange={setCategoryDialogOpen}
|
||||
transaction={categoryDialogTransaction}
|
||||
onSelectCategory={handleCategoryDialogSelect}
|
||||
isProcessing={categoryDialogProcessing}
|
||||
<TransactionBookingDialog
|
||||
open={bookingDialogOpen}
|
||||
onOpenChange={setBookingDialogOpen}
|
||||
transaction={bookingDialogTransaction}
|
||||
onBooked={handleTransactionBooked}
|
||||
/>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
makeJournalEntry,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const mockCreateClient = vi.fn()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => mockCreateClient(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCorrectEntry = vi.fn()
|
||||
vi.mock('@/lib/core/bookkeeping/storno-service', () => ({
|
||||
correctEntry: (...args: unknown[]) => mockCorrectEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', {
|
||||
method: 'POST',
|
||||
body: { lines: [] },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 400 when lines are missing', async () => {
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Lines are required')
|
||||
})
|
||||
|
||||
it('returns 400 when lines array is empty', async () => {
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', {
|
||||
method: 'POST',
|
||||
body: { lines: [] },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Lines are required')
|
||||
})
|
||||
|
||||
it('returns reversal and corrected entries on success', async () => {
|
||||
const reversal = makeJournalEntry({
|
||||
id: 'reversal-1',
|
||||
reverses_id: 'entry-1',
|
||||
source_type: 'storno',
|
||||
})
|
||||
const corrected = makeJournalEntry({
|
||||
id: 'corrected-1',
|
||||
correction_of_id: 'entry-1',
|
||||
source_type: 'correction',
|
||||
})
|
||||
mockCorrectEntry.mockResolvedValue({ reversal, corrected })
|
||||
|
||||
const lines = [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
]
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', {
|
||||
method: 'POST',
|
||||
body: { lines },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { reversal: unknown; corrected: unknown } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.reversal).toEqual(reversal)
|
||||
expect(body.data.corrected).toEqual(corrected)
|
||||
expect(mockCorrectEntry).toHaveBeenCalledWith('user-1', 'entry-1', lines)
|
||||
})
|
||||
|
||||
it('returns 400 when correctEntry throws for unbalanced lines', async () => {
|
||||
mockCorrectEntry.mockRejectedValue(
|
||||
new Error('Corrected entry is not balanced: debits (1000) != credits (500)')
|
||||
)
|
||||
|
||||
const lines = [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
]
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', {
|
||||
method: 'POST',
|
||||
body: { lines },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('not balanced')
|
||||
})
|
||||
|
||||
it('returns 400 when entry is not found or not posted', async () => {
|
||||
mockCorrectEntry.mockRejectedValue(new Error('Can only correct posted entries'))
|
||||
|
||||
const lines = [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
]
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', {
|
||||
method: 'POST',
|
||||
body: { lines },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Can only correct posted entries')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: { lines: CreateJournalEntryLineInput[] }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!body.lines || !Array.isArray(body.lines) || body.lines.length === 0) {
|
||||
return NextResponse.json({ error: 'Lines are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await correctEntry(user.id, id, body.lines)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to correct entry' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateJournalEntryInput } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSettings, saveSettings } from '@/extensions/general/ai-categorization'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/extensions/ai-categorization/settings
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { categorizeTransactions } from '@/extensions/general/ai-categorization'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CategorizationSuggestion } from '@/extensions/general/ai-categorization/categorizer'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/extensions/ai-categorization/suggestions?transaction_ids=id1,id2,...
|
||||
* Fetch pre-computed AI suggestions for given transaction IDs
|
||||
|
||||
@@ -2,8 +2,11 @@ import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync'
|
||||
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/general/enable-banking/lib/api-client'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/extensions/enable-banking/sync/cron
|
||||
* Automatic daily bank transaction sync
|
||||
|
||||
@@ -143,6 +143,8 @@ export async function POST(request: Request) {
|
||||
vat_amount: item.vatRate && item.lineTotal ? (item.lineTotal * item.vatRate) / (100 + item.vatRate) : null,
|
||||
extraction_confidence: item.confidence,
|
||||
suggested_category: item.suggestedCategory,
|
||||
category: item.category,
|
||||
bas_account: item.basAccount,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// Legacy general extensions default to enabled when no toggle row exists
|
||||
const LEGACY_GENERAL_EXTENSIONS = [
|
||||
'receipt-ocr',
|
||||
'ai-categorization',
|
||||
'ai-chat',
|
||||
'push-notifications',
|
||||
'enable-banking',
|
||||
]
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ sector: string; slug: string }> }
|
||||
@@ -21,7 +30,14 @@ export async function GET(
|
||||
.eq('extension_slug', slug)
|
||||
.single()
|
||||
|
||||
return NextResponse.json({ data: data ?? { enabled: false } })
|
||||
if (data) {
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
// No toggle row: legacy general extensions default to enabled
|
||||
const defaultEnabled =
|
||||
sector === 'general' && LEGACY_GENERAL_EXTENSIONS.includes(slug)
|
||||
return NextResponse.json({ data: { enabled: defaultEnabled } })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
|
||||
@@ -55,25 +55,33 @@ export async function POST(request: Request) {
|
||||
const docType: InvoiceDocumentType = document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
|
||||
// Build items with line totals
|
||||
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => ({
|
||||
id: `preview-${index}`,
|
||||
invoice_id: 'preview',
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: Math.round(item.quantity * item.unit_price * 100) / 100,
|
||||
vat_rate: item.vat_rate ?? vatRules.rate,
|
||||
vat_amount: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}))
|
||||
// Build items with line totals and per-item VAT
|
||||
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => {
|
||||
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
|
||||
const rate = item.vat_rate ?? vatRules.rate
|
||||
return {
|
||||
id: `preview-${index}`,
|
||||
invoice_id: 'preview',
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: rate,
|
||||
vat_amount: isDeliveryNote ? 0 : Math.round(lineTotal * (rate / 100) * 100) / 100,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0)
|
||||
const vatAmount = isDeliveryNote ? 0 : Math.round(subtotal * (vatRules.rate / 100) * 100) / 100
|
||||
const vatAmount = isDeliveryNote ? 0 : invoiceItems.reduce((sum, item) => sum + item.vat_amount, 0)
|
||||
const total = isDeliveryNote ? 0 : subtotal + vatAmount
|
||||
|
||||
// Derive vat_rate from items: single rate → that rate, mixed → null
|
||||
const itemRates = new Set(invoiceItems.map((item) => item.vat_rate))
|
||||
const effectiveVatRate = isDeliveryNote ? 0 : (itemRates.size === 1 ? itemRates.values().next().value! : null)
|
||||
|
||||
// Construct a temporary Invoice-like object
|
||||
const previewInvoice = {
|
||||
id: 'preview',
|
||||
@@ -93,7 +101,7 @@ export async function POST(request: Request) {
|
||||
total,
|
||||
total_sek: null,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: isDeliveryNote ? 0 : vatRules.rate,
|
||||
vat_rate: effectiveVatRate,
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
your_reference: your_reference || null,
|
||||
our_reference: our_reference || null,
|
||||
|
||||
@@ -12,6 +12,10 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
makeJournalEntry,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/transactions/[id]/book', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const validBody = {
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2025-01-15',
|
||||
description: 'Test booking',
|
||||
lines: [
|
||||
{ account_number: '6200', debit_amount: 500, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 500 },
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 400 when missing required fields', async () => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: { fiscal_period_id: 'period-1' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('Missing required fields')
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-999/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Transaction not found')
|
||||
})
|
||||
|
||||
it('returns 409 when transaction already has a journal entry', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: 'je-existing',
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toBe('Transaction already has a journal entry')
|
||||
})
|
||||
|
||||
it('returns 400 when journal entry creation fails (engine error)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
enqueue({ data: tx, error: null })
|
||||
|
||||
mockCreateJournalEntry.mockRejectedValue(new Error('Entry is not balanced'))
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Entry is not balanced')
|
||||
})
|
||||
|
||||
it('creates journal entry and links to transaction (happy path)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
journal_entry_id: null,
|
||||
})
|
||||
const je = makeJournalEntry({ id: 'je-new' })
|
||||
|
||||
// Fetch transaction
|
||||
enqueue({ data: tx, error: null })
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue(je)
|
||||
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string
|
||||
data: { id: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-new')
|
||||
expect(body.data.id).toBe('je-new')
|
||||
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', {
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2025-01-15',
|
||||
description: 'Test booking',
|
||||
source_type: 'bank_transaction',
|
||||
source_id: 'tx-1',
|
||||
lines: validBody.lines,
|
||||
})
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'transaction.categorized' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 500 when transaction update fails', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
const je = makeJournalEntry({ id: 'je-new' })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
mockCreateJournalEntry.mockResolvedValue(je)
|
||||
// Update fails
|
||||
enqueue({ data: null, error: { message: 'Update failed' } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Failed to update transaction')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import type { CreateJournalEntryLineInput, Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface BookRequest {
|
||||
fiscal_period_id: string
|
||||
entry_date: string
|
||||
description: string
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: BookRequest = await request.json()
|
||||
const { fiscal_period_id, entry_date, description, lines } = body
|
||||
|
||||
if (!fiscal_period_id || !entry_date || !description || !lines?.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: fiscal_period_id, entry_date, description, lines' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch transaction (validates ownership)
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Reject if already booked
|
||||
if (transaction.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaction already has a journal entry' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create journal entry via the engine
|
||||
let journalEntry
|
||||
try {
|
||||
journalEntry = await createJournalEntry(user.id, {
|
||||
fiscal_period_id,
|
||||
entry_date,
|
||||
description,
|
||||
source_type: 'bank_transaction',
|
||||
source_id: id,
|
||||
lines,
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Link transaction to the journal entry
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: journalEntry.id,
|
||||
is_business: true,
|
||||
category: 'uncategorized',
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update transaction' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Emit event (non-blocking)
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'transaction.categorized',
|
||||
payload: {
|
||||
transaction: transaction as Transaction,
|
||||
account: lines[0]?.account_number || '',
|
||||
taxCode: '',
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: journalEntry,
|
||||
journal_entry_id: journalEntry.id,
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
@@ -128,7 +128,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
// Fetch company settings
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
// ensureFiscalPeriod: check existing
|
||||
enqueue({ data: { id: 'period-1' }, error: null })
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockSaveUserMappingRule.mockResolvedValue(undefined)
|
||||
@@ -177,7 +177,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: { id: 'period-1' }, error: null })
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: { id: 'period-1' }, error: null })
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
@@ -228,6 +228,33 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
expect(body.error).toBe('Failed to update transaction')
|
||||
})
|
||||
|
||||
it('returns 400 when mapping result has empty debit_account', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '',
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Invalid account mapping: debit and credit accounts are required')
|
||||
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('categorizes as private when is_business is false', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
@@ -237,7 +264,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: { id: 'period-1' }, error: null })
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
|
||||
@@ -199,6 +199,14 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that both accounts are present before proceeding
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid account mapping: debit and credit accounts are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure fiscal period exists for the transaction date
|
||||
await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth)
|
||||
|
||||
|
||||
@@ -14,14 +14,11 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
}))
|
||||
|
||||
const mockCreateInvoicePaymentJournalEntry = vi.fn()
|
||||
const mockCreateInvoiceCashEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoicePaymentJournalEntry: (...args: unknown[]) => mockCreateInvoicePaymentJournalEntry(...args),
|
||||
createInvoiceCashEntry: (...args: unknown[]) => mockCreateInvoiceCashEntry(...args),
|
||||
getRevenueAccount: vi.fn().mockReturnValue('3001'),
|
||||
getOutputVatAccount: vi.fn().mockReturnValue('2611'),
|
||||
}))
|
||||
@@ -35,7 +32,6 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
@@ -161,7 +157,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update invoice to paid
|
||||
enqueue({ data: null, error: null })
|
||||
@@ -186,16 +182,11 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect(body.paid_amount).toBe(12500)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
|
||||
// Verify accrual journal entry: debit 1930, credit 1510
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
// Verify accrual payment entry was called
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
source_type: 'invoice_paid',
|
||||
lines: expect.arrayContaining([
|
||||
expect.objectContaining({ account_number: '1930', debit_amount: 12500 }),
|
||||
expect.objectContaining({ account_number: '1510', credit_amount: 12500 }),
|
||||
]),
|
||||
})
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
'2024-06-15'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -207,7 +198,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
// Update invoice
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redirect } from 'next/navigation'
|
||||
import DashboardNav from '@/components/dashboard/DashboardNav'
|
||||
import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||
import type { Deadline, ReceiptQueueSummary } from '@/types'
|
||||
import { ChatWidget } from '@/components/chat'
|
||||
|
||||
export default async function RootPage() {
|
||||
const supabase = await createClient()
|
||||
@@ -190,6 +191,7 @@ export default async function RootPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ChatWidget />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -139,7 +139,10 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
setSearch(newValue)
|
||||
onChange(newValue)
|
||||
// Only emit valid account numbers to parent
|
||||
if (/^\d{4}$/.test(newValue) && accounts.some(a => a.account_number === newValue)) {
|
||||
onChange(newValue)
|
||||
}
|
||||
if (!isOpen) {
|
||||
setIsOpen(true)
|
||||
}
|
||||
@@ -149,6 +152,15 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// Small delay to allow dropdown click to fire first
|
||||
setTimeout(() => {
|
||||
if (!accounts.some(a => a.account_number === search)) {
|
||||
setSearch(value)
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// Find matching account for helper text
|
||||
const matchedAccount = useMemo(() => {
|
||||
if (!value || value.length !== 4) return null
|
||||
@@ -162,16 +174,16 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
|
||||
value={search}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="1930"
|
||||
className="font-mono h-8"
|
||||
maxLength={4}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{/* Account name helper text (md+ screens only) */}
|
||||
{/* Account name helper text */}
|
||||
{matchedAccount && (
|
||||
<p className="hidden md:block text-[11px] text-muted-foreground truncate mt-0.5 leading-tight">
|
||||
<p className="text-[11px] text-muted-foreground truncate mt-0.5 leading-tight">
|
||||
{matchedAccount.account_name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
|
||||
interface CorrectionLine {
|
||||
account_number: string
|
||||
debit_amount: string
|
||||
credit_amount: string
|
||||
line_description: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JournalEntry
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCorrected: () => void
|
||||
}
|
||||
|
||||
export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCorrected }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [lines, setLines] = useState<CorrectionLine[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const originalLines = ((entry.lines || []) as JournalEntryLine[])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Pre-fill with original entry's lines
|
||||
setLines(
|
||||
originalLines.map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: Number(l.debit_amount) > 0 ? String(Number(l.debit_amount)) : '',
|
||||
credit_amount: Number(l.credit_amount) > 0 ? String(Number(l.credit_amount)) : '',
|
||||
line_description: l.line_description || '',
|
||||
}))
|
||||
)
|
||||
fetchAccounts()
|
||||
}
|
||||
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchAccounts() {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts')
|
||||
const { data } = await res.json()
|
||||
setAccounts(data || [])
|
||||
} catch {
|
||||
// Accounts will be empty — user can still type account numbers manually
|
||||
}
|
||||
}
|
||||
|
||||
const updateLine = (index: number, field: keyof CorrectionLine, value: string) => {
|
||||
setLines((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)))
|
||||
}
|
||||
|
||||
const addLine = () => {
|
||||
setLines((prev) => [...prev, { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }])
|
||||
}
|
||||
|
||||
const removeLine = (index: number) => {
|
||||
setLines((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
||||
const roundedDebit = Math.round(totalDebit * 100) / 100
|
||||
const roundedCredit = Math.round(totalCredit * 100) / 100
|
||||
const isBalanced = roundedDebit === roundedCredit && roundedDebit > 0
|
||||
|
||||
const hasValidLines = lines.length >= 2 && lines.every((l) => l.account_number.length === 4)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isBalanced || !hasValidLines) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const apiLines = lines.map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: parseFloat(l.debit_amount) || 0,
|
||||
credit_amount: parseFloat(l.credit_amount) || 0,
|
||||
line_description: l.line_description || undefined,
|
||||
}))
|
||||
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/correct`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lines: apiLines }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const { error } = await res.json()
|
||||
throw new Error(error || 'Failed to create correction')
|
||||
}
|
||||
|
||||
toast({ title: 'Ändringsverifikation skapad', description: 'Storno och rättelse har bokförts.' })
|
||||
onOpenChange(false)
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: err instanceof Error ? err.message : 'Kunde inte skapa ändringsverifikation',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skapa ändringsverifikation</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Original entry (read-only) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono">{entry.voucher_series}{entry.voucher_number}</span>
|
||||
<span>{entry.entry_date}</span>
|
||||
<Badge variant="outline" className="text-xs">Original</Badge>
|
||||
</div>
|
||||
<p className="text-sm">{entry.description}</p>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-1.5 w-48">Konto</th>
|
||||
<th className="py-1.5">Beskrivning</th>
|
||||
<th className="py-1.5 w-28 text-right">Debet</th>
|
||||
<th className="py-1.5 w-28 text-right">Kredit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{originalLines.map((line) => (
|
||||
<tr key={line.id} className="border-b last:border-0">
|
||||
<td className="py-1.5"><AccountNumber number={line.account_number} showName /></td>
|
||||
<td className="py-1.5 text-muted-foreground">{line.line_description || ''}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{Number(line.debit_amount) > 0
|
||||
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
|
||||
: ''}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{Number(line.credit_amount) > 0
|
||||
? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
|
||||
: ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t my-2" />
|
||||
|
||||
{/* Corrected lines (editable) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Rättade rader</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{lines.map((line, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_1fr_120px_120px_auto] gap-2 items-start">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={accounts}
|
||||
onChange={(v) => updateLine(index, 'account_number', v)}
|
||||
/>
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
|
||||
placeholder="Beskrivning"
|
||||
className="h-8"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
placeholder="Debet"
|
||||
className="h-8 text-right"
|
||||
min={0}
|
||||
step="0.01"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
placeholder="Kredit"
|
||||
className="h-8 text-right"
|
||||
min={0}
|
||||
step="0.01"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => removeLine(index)}
|
||||
disabled={lines.length <= 2}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={addLine}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
|
||||
{/* Balance summary */}
|
||||
<div className="flex justify-end gap-6 text-sm pt-2 border-t">
|
||||
<div>
|
||||
<span className="text-muted-foreground mr-2">Debet:</span>
|
||||
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
|
||||
{roundedDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mr-2">Kredit:</span>
|
||||
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
|
||||
{roundedCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isBalanced && roundedDebit + roundedCredit > 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Debet och kredit måste vara lika och större än 0.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isBalanced || !hasValidLines || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Skapar...' : 'Skapa ändringsverifikation'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -12,29 +12,48 @@ import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntry
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount } from '@/types'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types'
|
||||
|
||||
interface Props {
|
||||
onCreated?: () => void
|
||||
}
|
||||
|
||||
interface FormLine {
|
||||
export interface FormLine {
|
||||
account_number: string
|
||||
debit_amount: string
|
||||
credit_amount: string
|
||||
line_description: string
|
||||
}
|
||||
|
||||
export default function JournalEntryForm({ onCreated }: Props) {
|
||||
interface Props {
|
||||
onCreated?: () => void
|
||||
onEntryCreated?: (entryId: string) => void
|
||||
initialLines?: FormLine[]
|
||||
initialDate?: string
|
||||
initialDescription?: string
|
||||
sourceType?: JournalEntrySourceType
|
||||
sourceId?: string
|
||||
submitUrl?: string
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }
|
||||
|
||||
export default function JournalEntryForm({
|
||||
onCreated,
|
||||
onEntryCreated,
|
||||
initialLines,
|
||||
initialDate,
|
||||
initialDescription,
|
||||
sourceType,
|
||||
sourceId,
|
||||
submitUrl,
|
||||
embedded,
|
||||
}: Props) {
|
||||
const { toast } = useToast()
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
const [entryDate, setEntryDate] = useState(new Date().toISOString().split('T')[0])
|
||||
const [description, setDescription] = useState('')
|
||||
const [lines, setLines] = useState<FormLine[]>([
|
||||
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
|
||||
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
|
||||
])
|
||||
const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0])
|
||||
const [description, setDescription] = useState(initialDescription ?? '')
|
||||
const [lines, setLines] = useState<FormLine[]>(
|
||||
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
|
||||
)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
@@ -63,10 +82,7 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
}
|
||||
|
||||
const addLine = () => {
|
||||
setLines([
|
||||
...lines,
|
||||
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
|
||||
])
|
||||
setLines([...lines, { ...BLANK_LINE }])
|
||||
}
|
||||
|
||||
const removeLine = (index: number) => {
|
||||
@@ -85,12 +101,20 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
updated[index].debit_amount = ''
|
||||
}
|
||||
|
||||
// Auto-fill line description from account name when selecting an account
|
||||
if (field === 'account_number' && value && !updated[index].line_description) {
|
||||
const account = accounts.find((a) => a.account_number === value)
|
||||
if (account) {
|
||||
updated[index].line_description = account.account_name
|
||||
}
|
||||
}
|
||||
|
||||
setLines(updated)
|
||||
}
|
||||
|
||||
const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
||||
const isBalanced = Math.abs(totalDebit - totalCredit) < 0.01 && totalDebit > 0
|
||||
const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0
|
||||
|
||||
const handleReview = () => {
|
||||
if (!selectedPeriod || !description || !isBalanced) return
|
||||
@@ -109,14 +133,17 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
line_description: l.line_description || undefined,
|
||||
}))
|
||||
|
||||
const res = await fetch('/api/bookkeeping/journal-entries', {
|
||||
const url = submitUrl ?? '/api/bookkeeping/journal-entries'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fiscal_period_id: selectedPeriod,
|
||||
entry_date: entryDate,
|
||||
description,
|
||||
source_type: 'manual',
|
||||
source_type: sourceType ?? 'manual',
|
||||
source_id: sourceId,
|
||||
lines: entryLines,
|
||||
}),
|
||||
})
|
||||
@@ -131,8 +158,8 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
})
|
||||
} else {
|
||||
// Link uploaded documents to the new journal entry (non-blocking)
|
||||
const journalEntryId = result.data?.id
|
||||
if (journalEntryId) {
|
||||
const journalEntryId = result.data?.id ?? result.journal_entry_id
|
||||
if (journalEntryId && uploadedFiles.length > 0) {
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
@@ -149,163 +176,160 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
|
||||
toast({
|
||||
title: 'Verifikation skapad',
|
||||
description: `Verifikation ${result.data?.voucher_series}${result.data?.voucher_number} har skapats.`,
|
||||
description: `Verifikation ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har skapats.`,
|
||||
})
|
||||
setShowReview(false)
|
||||
// Reset form
|
||||
setDescription('')
|
||||
setUploadedFiles([])
|
||||
setLines([
|
||||
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
|
||||
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
|
||||
])
|
||||
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
|
||||
onCreated?.()
|
||||
if (journalEntryId) {
|
||||
onEntryCreated?.(journalEntryId)
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ny verifikation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>Räkenskapsår</Label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{periods.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Datum</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Beskrivning</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Verifikationstext..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entry lines */}
|
||||
const formContent = (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-32 text-right">Debet</th>
|
||||
<th className="py-2 w-32 text-right">Kredit</th>
|
||||
<th className="py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line, index) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="py-1">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={accounts}
|
||||
onChange={(num) => updateLine(index, 'account_number', num)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 px-1">
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
|
||||
placeholder="Radtext..."
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
placeholder="0,00"
|
||||
className="text-right h-8"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
placeholder="0,00"
|
||||
className="text-right h-8"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeLine(index)}
|
||||
disabled={lines.length <= 2}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="font-semibold">
|
||||
<td colSpan={2} className="py-2">
|
||||
Summa
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 text-right ${
|
||||
isBalanced ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 text-right ${
|
||||
isBalanced ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
className="mt-2"
|
||||
<Label>Räkenskapsår</Label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
{periods.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Datum</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Beskrivning</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Verifikationstext..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document attachments */}
|
||||
{/* Entry lines */}
|
||||
<div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-32 text-right">Debet</th>
|
||||
<th className="py-2 w-32 text-right">Kredit</th>
|
||||
<th className="py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line, index) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="py-1">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={accounts}
|
||||
onChange={(num) => updateLine(index, 'account_number', num)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 px-1">
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
|
||||
placeholder="Radtext..."
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
placeholder="0,00"
|
||||
className="text-right h-8"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
placeholder="0,00"
|
||||
className="text-right h-8"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeLine(index)}
|
||||
disabled={lines.length <= 2}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="font-semibold">
|
||||
<td colSpan={2} className="py-2">
|
||||
Summa
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 text-right ${
|
||||
isBalanced ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 text-right ${
|
||||
isBalanced ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
className="mt-2"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Document attachments */}
|
||||
{!embedded && (
|
||||
<div>
|
||||
<Label className="mb-2 block">Underlag</Label>
|
||||
<DocumentUploadZone
|
||||
@@ -313,47 +337,62 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
onFilesChange={setUploadedFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBalanced && totalDebit > 0 && (
|
||||
<p className="text-sm text-red-600">
|
||||
Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr
|
||||
</p>
|
||||
)}
|
||||
{!isBalanced && totalDebit > 0 && (
|
||||
<p className="text-sm text-red-600">
|
||||
Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
onClick={handleReview}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting || isUploading}
|
||||
>
|
||||
Granska & skapa
|
||||
</Button>
|
||||
{(!description || !selectedPeriod || isUploading) && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
|
||||
{!description && <p>Ange en beskrivning</p>}
|
||||
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
|
||||
{isUploading && <p>Vänta tills filerna laddats upp</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmationDialog
|
||||
open={showReview}
|
||||
onOpenChange={setShowReview}
|
||||
onConfirm={handleConfirm}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Granska verifikation"
|
||||
warningText="En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno."
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
onClick={handleReview}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting || isUploading}
|
||||
>
|
||||
<JournalEntryReviewContent
|
||||
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
|
||||
entryDate={entryDate}
|
||||
description={description}
|
||||
lines={lines}
|
||||
totalDebit={totalDebit}
|
||||
totalCredit={totalCredit}
|
||||
attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
Granska & skapa
|
||||
</Button>
|
||||
{(!description || !selectedPeriod || isUploading) && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
|
||||
{!description && <p>Ange en beskrivning</p>}
|
||||
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
|
||||
{isUploading && <p>Vänta tills filerna laddats upp</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmationDialog
|
||||
open={showReview}
|
||||
onOpenChange={setShowReview}
|
||||
onConfirm={handleConfirm}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Granska verifikation"
|
||||
warningText="En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno."
|
||||
>
|
||||
<JournalEntryReviewContent
|
||||
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
|
||||
entryDate={entryDate}
|
||||
description={description}
|
||||
lines={lines}
|
||||
totalDebit={totalDebit}
|
||||
totalCredit={totalCredit}
|
||||
attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return formContent
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ny verifikation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formContent}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
|
||||
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
const NEEDS_ATTACHMENT = new Set([
|
||||
@@ -32,6 +33,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
const [page, setPage] = useState(0)
|
||||
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
|
||||
const [showMissingOnly, setShowMissingOnly] = useState(false)
|
||||
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
|
||||
const pageSize = 20
|
||||
|
||||
const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => {
|
||||
@@ -209,7 +211,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2 w-48">Konto</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Debet</th>
|
||||
<th className="py-2 w-28 text-right">Kredit</th>
|
||||
@@ -220,7 +222,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((line) => (
|
||||
<tr key={line.id} className="border-b last:border-0">
|
||||
<td className="py-2"><AccountNumber number={line.account_number} /></td>
|
||||
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
|
||||
<td className="py-2 text-muted-foreground">
|
||||
{line.line_description || ''}
|
||||
</td>
|
||||
@@ -264,6 +266,18 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
journalEntryId={entry.id}
|
||||
onCountChange={(c) => handleAttachmentCountChange(entry.id, c)}
|
||||
/>
|
||||
|
||||
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
|
||||
<div className="mt-4 pt-3 border-t flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCorrectionEntry(entry)}
|
||||
>
|
||||
Skapa ändringsverifikation
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
@@ -271,6 +285,16 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Correction dialog */}
|
||||
{correctionEntry && (
|
||||
<CorrectionEntryDialog
|
||||
entry={correctionEntry}
|
||||
open={!!correctionEntry}
|
||||
onOpenChange={(open) => { if (!open) setCorrectionEntry(null) }}
|
||||
onCorrected={() => { setCorrectionEntry(null); fetchEntries() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{count > pageSize && (
|
||||
<div className="flex justify-center gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChatPanel } from './ChatPanel'
|
||||
import { MessageCircle, X } from 'lucide-react'
|
||||
@@ -8,6 +8,47 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
export function ChatWidget() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
// Default to true for legacy compatibility (ai-chat defaults to enabled)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
|
||||
// Fetch initial toggle state
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/toggles/general/ai-chat')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
// Legacy: enabled by default when no toggle row exists
|
||||
setEnabled(data?.enabled ?? true)
|
||||
}
|
||||
} catch {
|
||||
// Keep default (enabled) on fetch failure
|
||||
}
|
||||
}
|
||||
check()
|
||||
}, [])
|
||||
|
||||
// Listen for real-time toggle changes
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { sectorSlug, extensionSlug, enabled: newValue } = (e as CustomEvent).detail
|
||||
if (sectorSlug === 'general' && extensionSlug === 'ai-chat') {
|
||||
setEnabled(newValue)
|
||||
if (!newValue) setIsOpen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('extension-toggle-changed', handler)
|
||||
return () => window.removeEventListener('extension-toggle-changed', handler)
|
||||
}, [])
|
||||
|
||||
// Allow other components to open the chat via custom event
|
||||
useEffect(() => {
|
||||
const handler = () => setIsOpen(true)
|
||||
window.addEventListener('open-ai-chat', handler)
|
||||
return () => window.removeEventListener('open-ai-chat', handler)
|
||||
}, [])
|
||||
|
||||
if (!enabled) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Landmark,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
@@ -179,6 +180,8 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
||||
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
||||
|
||||
const openAiChat = () => window.dispatchEvent(new Event('open-ai-chat'))
|
||||
|
||||
// Quick action items
|
||||
const quickActions = [
|
||||
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
|
||||
@@ -400,6 +403,18 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{/* AI assistant quick action */}
|
||||
<button onClick={openAiChat} className="group text-left">
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
|
||||
<div className="p-2 rounded-lg bg-muted/50">
|
||||
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">AI-assistent</p>
|
||||
<p className="text-xs text-muted-foreground truncate hidden md:block">Fråga om bokföring</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||
import { getVatSummaryFromItems } from '@/lib/invoices/vat-rules'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { Customer, Currency, VatTreatment } from '@/types'
|
||||
import type { Customer, Currency } from '@/types'
|
||||
|
||||
interface ReviewItem {
|
||||
description: string
|
||||
@@ -21,10 +21,8 @@ interface InvoiceReviewContentProps {
|
||||
currency: Currency
|
||||
items: ReviewItem[]
|
||||
subtotal: number
|
||||
vatRate: number
|
||||
vatAmount: number
|
||||
total: number
|
||||
vatTreatment: VatTreatment
|
||||
yourReference?: string
|
||||
ourReference?: string
|
||||
notes?: string
|
||||
@@ -37,10 +35,8 @@ export function InvoiceReviewContent({
|
||||
currency,
|
||||
items,
|
||||
subtotal,
|
||||
vatRate,
|
||||
vatAmount,
|
||||
total,
|
||||
vatTreatment,
|
||||
yourReference,
|
||||
ourReference,
|
||||
notes,
|
||||
@@ -52,24 +48,20 @@ export function InvoiceReviewContent({
|
||||
non_eu_business: 'Utanför EU',
|
||||
}
|
||||
|
||||
// Check if items have mixed VAT rates
|
||||
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined)
|
||||
const uniqueRates = hasPerLineVat
|
||||
? new Set(items.map((item) => item.vat_rate ?? vatRate))
|
||||
: new Set([vatRate])
|
||||
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
|
||||
// Derive VAT summary from items
|
||||
const vatSummary = getVatSummaryFromItems(items)
|
||||
|
||||
// Calculate per-rate VAT breakdown
|
||||
const vatByRate = new Map<number, number>()
|
||||
if (hasPerLineVat) {
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? vatRate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
|
||||
}
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? 25
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
|
||||
}
|
||||
|
||||
const showVatColumn = vatByRate.size > 1
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Customer info */}
|
||||
@@ -85,7 +77,7 @@ export function InvoiceReviewContent({
|
||||
|
||||
{/* VAT treatment */}
|
||||
<Badge className="text-sm px-3 py-1">
|
||||
{getVatTreatmentLabel(vatTreatment)}
|
||||
{vatSummary.label}
|
||||
</Badge>
|
||||
|
||||
{/* Dates */}
|
||||
@@ -120,7 +112,7 @@ export function InvoiceReviewContent({
|
||||
<td className="py-2 text-center">{item.unit}</td>
|
||||
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
|
||||
{showVatColumn && (
|
||||
<td className="py-2 text-right">{item.vat_rate ?? vatRate}%</td>
|
||||
<td className="py-2 text-right">{item.vat_rate ?? 25}%</td>
|
||||
)}
|
||||
<td className="py-2 text-right">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
@@ -136,21 +128,19 @@ export function InvoiceReviewContent({
|
||||
<span className="text-muted-foreground">Delsumma</span>
|
||||
<span>{formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
{vatByRate.size > 1 ? (
|
||||
// Per-rate breakdown
|
||||
Array.from(vatByRate.entries())
|
||||
.filter(([, vat]) => vat > 0)
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([rate, vat]) => (
|
||||
<div key={rate} className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms {rate}%</span>
|
||||
<span>{formatCurrency(vat, currency)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
{Array.from(vatByRate.entries())
|
||||
.filter(([, vat]) => vat > 0)
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([rate, vat]) => (
|
||||
<div key={rate} className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms {rate}%</span>
|
||||
<span>{formatCurrency(vat, currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
{Array.from(vatByRate.values()).every((vat) => vat === 0) && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms ({vatRate}%)</span>
|
||||
<span>{formatCurrency(vatAmount, currency)}</span>
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{formatCurrency(0, currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { TransactionCategory } from '@/types'
|
||||
import type { TransactionCategory, VatTreatment } from '@/types'
|
||||
|
||||
interface CategoryExpandedDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
onSelectCategory: (category: TransactionCategory) => void
|
||||
onSelectCategory: (category: TransactionCategory, vatTreatment?: VatTreatment) => void
|
||||
isProcessing: boolean
|
||||
}
|
||||
|
||||
@@ -23,10 +25,23 @@ export default function CategoryExpandedDialog({
|
||||
onSelectCategory,
|
||||
isProcessing,
|
||||
}: CategoryExpandedDialogProps) {
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVatTreatment('standard_25')
|
||||
}
|
||||
}, [open, transaction?.id])
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
|
||||
const handleSelectCategory = (category: TransactionCategory) => {
|
||||
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
|
||||
onSelectCategory(category, resolvedVat)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -62,6 +77,26 @@ export default function CategoryExpandedDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* VAT treatment selector */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
|
||||
<Select
|
||||
value={vatTreatment}
|
||||
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAT_TREATMENT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Category grid */}
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
@@ -73,7 +108,7 @@ export default function CategoryExpandedDialog({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start text-xs"
|
||||
onClick={() => onSelectCategory(cat.value)}
|
||||
onClick={() => handleSelectCategory(cat.value)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{cat.label}
|
||||
@@ -90,7 +125,7 @@ export default function CategoryExpandedDialog({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start text-xs"
|
||||
onClick={() => onSelectCategory(cat.value)}
|
||||
onClick={() => handleSelectCategory(cat.value)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{cat.label}
|
||||
|
||||
@@ -48,6 +48,13 @@ export default function SwipeCategorizationView({
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
|
||||
// Clear VAT treatment when switching to a liability/equity account (class 2)
|
||||
useEffect(() => {
|
||||
if (accountOverride.startsWith('2') && vatTreatment !== 'none') {
|
||||
setVatTreatment('none')
|
||||
}
|
||||
}, [accountOverride]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch accounts on mount
|
||||
useEffect(() => {
|
||||
async function fetchAccounts() {
|
||||
@@ -375,7 +382,7 @@ export default function SwipeCategorizationView({
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleReviewConfirm}
|
||||
disabled={isProcessing}
|
||||
disabled={isProcessing || !accountOverride}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{isProcessing ? 'Bokför...' : 'Bokför'}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
|
||||
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
|
||||
interface TransactionBookingDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
onBooked: (transactionId: string, journalEntryId: string) => void
|
||||
}
|
||||
|
||||
function buildInitialLines(transaction: TransactionWithInvoice): FormLine[] {
|
||||
const amount = Math.round(Math.abs(transaction.amount_sek ?? transaction.amount) * 100) / 100
|
||||
const amountStr = amount.toFixed(2)
|
||||
const isExpense = transaction.amount < 0
|
||||
|
||||
if (isExpense) {
|
||||
return [
|
||||
{ account_number: '', debit_amount: amountStr, credit_amount: '', line_description: '' },
|
||||
{ account_number: '1930', debit_amount: '', credit_amount: amountStr, line_description: 'Företagskonto' },
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ account_number: '1930', debit_amount: amountStr, credit_amount: '', line_description: 'Företagskonto' },
|
||||
{ account_number: '', debit_amount: '', credit_amount: amountStr, line_description: '' },
|
||||
]
|
||||
}
|
||||
|
||||
export default function TransactionBookingDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
transaction,
|
||||
onBooked,
|
||||
}: TransactionBookingDialogProps) {
|
||||
if (!transaction) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bokför transaktion</DialogTitle>
|
||||
<DialogDescription>
|
||||
Skapa en verifikation för transaktionen
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Transaction summary */}
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div
|
||||
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
isIncome
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{isIncome ? (
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{transaction.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
</div>
|
||||
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<JournalEntryForm
|
||||
key={transaction.id}
|
||||
embedded
|
||||
initialLines={buildInitialLines(transaction)}
|
||||
initialDate={transaction.date}
|
||||
initialDescription={transaction.description}
|
||||
submitUrl={`/api/transactions/${transaction.id}/book`}
|
||||
sourceType="bank_transaction"
|
||||
sourceId={transaction.id}
|
||||
onEntryCreated={(entryId) => onBooked(transaction.id, entryId)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -14,11 +14,13 @@ import type { HistoryFilter } from './transaction-types'
|
||||
interface TransactionHistoryListProps {
|
||||
transactions: TransactionWithInvoice[]
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
}
|
||||
|
||||
export default function TransactionHistoryList({
|
||||
transactions,
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
}: TransactionHistoryListProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filter, setFilter] = useState<HistoryFilter>('all')
|
||||
@@ -134,7 +136,11 @@ export default function TransactionHistoryList({
|
||||
) : transaction.is_business === null ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-warning border-warning">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-warning border-warning cursor-pointer hover:bg-warning/10"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Ej bokförd
|
||||
</Badge>
|
||||
</>
|
||||
@@ -155,20 +161,32 @@ export default function TransactionHistoryList({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
transaction.amount > 0 ? 'text-success' : ''
|
||||
}`}
|
||||
>
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
{transaction.currency !== 'SEK' && transaction.amount_sek && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(transaction.amount_sek)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{transaction.is_business === null && !transaction.journal_entry_id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Bokför
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
transaction.amount > 0 ? 'text-success' : ''
|
||||
}`}
|
||||
>
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
{transaction.currency !== 'SEK' && transaction.amount_sek && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(transaction.amount_sek)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, FileText, MoreHorizontal, Loader2 } from 'lucide-react'
|
||||
import { ArrowUpRight, ArrowDownRight, FileText, Loader2 } from 'lucide-react'
|
||||
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
||||
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
@@ -178,15 +178,15 @@ export default function TransactionInboxCard({
|
||||
Privat
|
||||
</Button>
|
||||
|
||||
{/* More options */}
|
||||
{/* Open category dialog */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-muted-foreground"
|
||||
variant={!hasInvoiceMatch && !topSuggestion ? 'default' : 'outline'}
|
||||
className="h-8 text-xs"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
{!hasInvoiceMatch && !topSuggestion ? 'Bokför' : 'Bokför manuellt...'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -52,15 +52,21 @@ export async function getSettings(userId: string): Promise<PushNotificationSetti
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.from('notification_settings')
|
||||
.select(
|
||||
'period_locked_enabled, period_year_closed_enabled, invoice_sent_enabled, receipt_extracted_enabled, receipt_matched_enabled'
|
||||
)
|
||||
.eq('user_id', userId)
|
||||
.eq('extension_id', 'push-notifications')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<PushNotificationSettings>) }
|
||||
if (!data) return { ...DEFAULT_SETTINGS }
|
||||
return {
|
||||
periodLockedEnabled: data.period_locked_enabled ?? DEFAULT_SETTINGS.periodLockedEnabled,
|
||||
periodYearClosedEnabled: data.period_year_closed_enabled ?? DEFAULT_SETTINGS.periodYearClosedEnabled,
|
||||
invoiceSentEnabled: data.invoice_sent_enabled ?? DEFAULT_SETTINGS.invoiceSentEnabled,
|
||||
receiptExtractedEnabled: data.receipt_extracted_enabled ?? DEFAULT_SETTINGS.receiptExtractedEnabled,
|
||||
receiptMatchedEnabled: data.receipt_matched_enabled ?? DEFAULT_SETTINGS.receiptMatchedEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
@@ -74,15 +80,17 @@ export async function saveSettings(
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.from('notification_settings')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'push-notifications',
|
||||
key: 'settings',
|
||||
value: merged,
|
||||
period_locked_enabled: merged.periodLockedEnabled,
|
||||
period_year_closed_enabled: merged.periodYearClosedEnabled,
|
||||
invoice_sent_enabled: merged.invoiceSentEnabled,
|
||||
receipt_extracted_enabled: merged.receiptExtractedEnabled,
|
||||
receipt_matched_enabled: merged.receiptMatchedEnabled,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
|
||||
return merged
|
||||
|
||||
@@ -21,6 +21,11 @@ export interface NotificationSettings {
|
||||
quiet_end: string // time format "HH:MM"
|
||||
email_enabled: boolean
|
||||
push_enabled: boolean
|
||||
period_locked_enabled: boolean
|
||||
period_year_closed_enabled: boolean
|
||||
invoice_sent_enabled: boolean
|
||||
receipt_extracted_enabled: boolean
|
||||
receipt_matched_enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -183,6 +183,8 @@ async function handleDocumentUploaded(
|
||||
: null,
|
||||
extraction_confidence: item.confidence,
|
||||
suggested_category: item.suggestedCategory,
|
||||
category: item.category,
|
||||
bas_account: item.basAccount,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
getExpenseAccountForCategory,
|
||||
getDefaultAccountForCategory,
|
||||
getDefaultVatTreatmentForCategory,
|
||||
buildMappingResultFromCategory,
|
||||
} from '../category-mapping'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import type { TransactionCategory } from '@/types'
|
||||
|
||||
describe('getCategoryAccountMapping', () => {
|
||||
describe('income_products uses correct account', () => {
|
||||
@@ -81,6 +84,79 @@ describe('getDefaultAccountForCategory', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMappingResultFromCategory', () => {
|
||||
describe('reverse charge handling', () => {
|
||||
it('generates fiktiv moms lines for reverse charge expense', () => {
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'reverse_charge')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(2)
|
||||
|
||||
const debitLine = result.vat_lines.find((l) => l.account_number === '2645')
|
||||
expect(debitLine).toBeDefined()
|
||||
expect(debitLine!.debit_amount).toBe(250)
|
||||
expect(debitLine!.credit_amount).toBe(0)
|
||||
|
||||
const creditLine = result.vat_lines.find((l) => l.account_number === '2614')
|
||||
expect(creditLine).toBeDefined()
|
||||
expect(creditLine!.debit_amount).toBe(0)
|
||||
expect(creditLine!.credit_amount).toBe(250)
|
||||
})
|
||||
|
||||
it('does not generate regular input VAT (2641) for reverse charge', () => {
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
const result = buildMappingResultFromCategory('expense_equipment', tx, true, 'enskild_firma', 'reverse_charge')
|
||||
|
||||
const hasRegularVat = result.vat_lines.some((l) => l.account_number === '2641')
|
||||
expect(hasRegularVat).toBe(false)
|
||||
})
|
||||
|
||||
it('does not generate VAT lines for reverse charge on income', () => {
|
||||
const tx = makeTransaction({ amount: 1000 })
|
||||
const result = buildMappingResultFromCategory('income_services', tx, true, 'enskild_firma', 'reverse_charge')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not generate VAT lines for reverse charge on private transactions', () => {
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
const result = buildMappingResultFromCategory('expense_software', tx, false, 'enskild_firma', 'reverse_charge')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMappingResultFromCategory returns non-empty accounts', () => {
|
||||
const allCategories: TransactionCategory[] = [
|
||||
'income_services',
|
||||
'income_products',
|
||||
'income_other',
|
||||
'expense_equipment',
|
||||
'expense_software',
|
||||
'expense_travel',
|
||||
'expense_office',
|
||||
'expense_marketing',
|
||||
'expense_professional_services',
|
||||
'expense_education',
|
||||
'expense_bank_fees',
|
||||
'expense_card_fees',
|
||||
'expense_currency_exchange',
|
||||
'expense_other',
|
||||
'private',
|
||||
'uncategorized',
|
||||
]
|
||||
|
||||
it.each(allCategories)('returns non-empty debit_account and credit_account for "%s"', (category) => {
|
||||
const tx = makeTransaction({ amount: category.startsWith('income') ? 1000 : -1000 })
|
||||
const isBusiness = category !== 'private'
|
||||
const result = buildMappingResultFromCategory(category, tx, isBusiness)
|
||||
|
||||
expect(result.debit_account).toBeTruthy()
|
||||
expect(result.credit_account).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultVatTreatmentForCategory', () => {
|
||||
it('returns standard_25 for regular expense categories', () => {
|
||||
expect(getDefaultVatTreatmentForCategory('expense_equipment')).toBe('standard_25')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType, VatTreatment } from '@/types'
|
||||
import { getVatRate } from './vat-entries'
|
||||
import { getVatRate, generateReverseChargeLines } from './vat-entries'
|
||||
|
||||
/**
|
||||
* Maps TransactionCategory to BAS accounts for journal entry creation
|
||||
@@ -176,7 +176,19 @@ export function buildMappingResultFromCategory(
|
||||
const treatment = mapping.vatTreatment as VatTreatment | null
|
||||
if (isBusiness && treatment) {
|
||||
const vatRate = getVatRate(treatment)
|
||||
if (vatRate > 0) {
|
||||
if (treatment === 'reverse_charge' && transaction.amount < 0) {
|
||||
// EU reverse charge: fiktiv moms (offsetting entries)
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
const rcLines = generateReverseChargeLines(absAmount)
|
||||
for (const rcl of rcLines) {
|
||||
vatLines.push({
|
||||
account_number: rcl.account_number,
|
||||
debit_amount: rcl.debit_amount,
|
||||
credit_amount: rcl.credit_amount,
|
||||
description: rcl.line_description || '',
|
||||
})
|
||||
}
|
||||
} else if (vatRate > 0) {
|
||||
const grossAmount = Math.abs(transaction.amount)
|
||||
const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ function buildResult(rule: MappingRule, transaction: Transaction): MappingResult
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
const isExpense = transaction.amount < 0
|
||||
|
||||
let debitAccount = rule.debit_account || (isExpense ? '6900' : '1930')
|
||||
let debitAccount = rule.debit_account || (isExpense ? '6991' : '1930')
|
||||
let creditAccount = rule.credit_account || (isExpense ? '1930' : '3001')
|
||||
|
||||
// Check capitalization threshold for equipment
|
||||
@@ -183,7 +183,7 @@ function getDefaultResult(transaction: Transaction): MappingResult {
|
||||
|
||||
return {
|
||||
rule: null,
|
||||
debit_account: isExpense ? '6900' : '1930',
|
||||
debit_account: isExpense ? '6991' : '1930',
|
||||
credit_account: isExpense ? '1930' : '3001',
|
||||
risk_level: 'MEDIUM',
|
||||
confidence: 0.1,
|
||||
|
||||
@@ -39,6 +39,12 @@ export async function createTransactionJournalEntry(
|
||||
transaction: Transaction,
|
||||
mappingResult: MappingResult
|
||||
): Promise<JournalEntry | null> {
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
throw new Error(
|
||||
`Invalid mapping result: debit_account="${mappingResult.debit_account}", credit_account="${mappingResult.credit_account}". Both must be non-empty.`
|
||||
)
|
||||
}
|
||||
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, transaction.date)
|
||||
if (!fiscalPeriodId) {
|
||||
console.warn('No open fiscal period found for transaction date:', transaction.date)
|
||||
|
||||
@@ -128,81 +128,105 @@ export async function correctEntry(
|
||||
.eq('id', originalEntryId)
|
||||
|
||||
// ===== Step 2: Create corrected entry =====
|
||||
const correctedVoucherNumber = await getNextVoucherNumber(
|
||||
userId,
|
||||
original.fiscal_period_id,
|
||||
original.voucher_series || 'A'
|
||||
)
|
||||
|
||||
// Resolve account IDs for corrected lines
|
||||
const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))]
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id, account_number')
|
||||
.eq('user_id', userId)
|
||||
.in('account_number', accountNumbers)
|
||||
|
||||
const accountIdMap = new Map<string, string>()
|
||||
for (const account of accounts || []) {
|
||||
accountIdMap.set(account.account_number, account.id)
|
||||
// If anything in this step fails, we must roll back the reversal from step 1
|
||||
// to avoid leaving the ledger in an inconsistent state.
|
||||
async function rollbackReversal() {
|
||||
// Restore original entry to 'posted' status
|
||||
await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted', reversed_by_id: null })
|
||||
.eq('id', originalEntryId)
|
||||
// Delete the reversal entry (it was just created, safe to remove since
|
||||
// the DB trigger allows deleting draft entries and we need to clean up)
|
||||
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
|
||||
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
|
||||
}
|
||||
|
||||
const { data: correctedEntry, error: correctedError } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
fiscal_period_id: original.fiscal_period_id,
|
||||
voucher_number: correctedVoucherNumber,
|
||||
voucher_series: original.voucher_series || 'A',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
description: `Rättelse: ${original.description}`,
|
||||
source_type: 'correction',
|
||||
correction_of_id: originalEntryId,
|
||||
status: 'draft',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
let correctedEntry: typeof reversalEntry
|
||||
|
||||
if (correctedError || !correctedEntry) {
|
||||
throw new Error(`Failed to create corrected entry: ${correctedError?.message}`)
|
||||
}
|
||||
try {
|
||||
const correctedVoucherNumber = await getNextVoucherNumber(
|
||||
userId,
|
||||
original.fiscal_period_id,
|
||||
original.voucher_series || 'A'
|
||||
)
|
||||
|
||||
// Insert corrected lines
|
||||
const correctedLineInserts = correctedLines.map((line, index) => ({
|
||||
journal_entry_id: correctedEntry.id,
|
||||
account_number: line.account_number,
|
||||
account_id: accountIdMap.get(line.account_number) || null,
|
||||
debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
|
||||
credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
|
||||
currency: line.currency || 'SEK',
|
||||
amount_in_currency: line.amount_in_currency
|
||||
? Math.round(line.amount_in_currency * 100) / 100
|
||||
: null,
|
||||
exchange_rate: line.exchange_rate || null,
|
||||
line_description: line.line_description || null,
|
||||
tax_code: line.tax_code || null,
|
||||
cost_center: line.cost_center || null,
|
||||
project: line.project || null,
|
||||
sort_order: index,
|
||||
}))
|
||||
// Resolve account IDs for corrected lines
|
||||
const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))]
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id, account_number')
|
||||
.eq('user_id', userId)
|
||||
.in('account_number', accountNumbers)
|
||||
|
||||
const { error: correctedLinesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(correctedLineInserts)
|
||||
const accountIdMap = new Map<string, string>()
|
||||
for (const account of accounts || []) {
|
||||
accountIdMap.set(account.account_number, account.id)
|
||||
}
|
||||
|
||||
if (correctedLinesError) {
|
||||
await supabase.from('journal_entries').delete().eq('id', correctedEntry.id)
|
||||
throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`)
|
||||
}
|
||||
const { data: newEntry, error: correctedError } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
fiscal_period_id: original.fiscal_period_id,
|
||||
voucher_number: correctedVoucherNumber,
|
||||
voucher_series: original.voucher_series || 'A',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
description: `Rättelse: ${original.description}`,
|
||||
source_type: 'correction',
|
||||
correction_of_id: originalEntryId,
|
||||
status: 'draft',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// Post the corrected entry
|
||||
const { error: postCorrectedError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
.eq('id', correctedEntry.id)
|
||||
if (correctedError || !newEntry) {
|
||||
throw new Error(`Failed to create corrected entry: ${correctedError?.message}`)
|
||||
}
|
||||
|
||||
if (postCorrectedError) {
|
||||
throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`)
|
||||
correctedEntry = newEntry
|
||||
|
||||
// Insert corrected lines
|
||||
const correctedLineInserts = correctedLines.map((line, index) => ({
|
||||
journal_entry_id: correctedEntry.id,
|
||||
account_number: line.account_number,
|
||||
account_id: accountIdMap.get(line.account_number) || null,
|
||||
debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
|
||||
credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
|
||||
currency: line.currency || 'SEK',
|
||||
amount_in_currency: line.amount_in_currency
|
||||
? Math.round(line.amount_in_currency * 100) / 100
|
||||
: null,
|
||||
exchange_rate: line.exchange_rate || null,
|
||||
line_description: line.line_description || null,
|
||||
tax_code: line.tax_code || null,
|
||||
cost_center: line.cost_center || null,
|
||||
project: line.project || null,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
const { error: correctedLinesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(correctedLineInserts)
|
||||
|
||||
if (correctedLinesError) {
|
||||
await supabase.from('journal_entries').delete().eq('id', correctedEntry.id)
|
||||
throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`)
|
||||
}
|
||||
|
||||
// Post the corrected entry
|
||||
const { error: postCorrectedError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
.eq('id', correctedEntry.id)
|
||||
|
||||
if (postCorrectedError) {
|
||||
throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
// Roll back the reversal to restore ledger consistency
|
||||
await rollbackReversal()
|
||||
throw err
|
||||
}
|
||||
|
||||
// ===== Step 3: Fetch complete entries =====
|
||||
|
||||
@@ -25,6 +25,8 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
|
||||
from: vi.fn().mockImplementation(() => makeBuilder()),
|
||||
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
|
||||
storage: {
|
||||
getBucket: vi.fn().mockResolvedValue({ data: { id: 'documents' }, error: null }),
|
||||
createBucket: vi.fn().mockResolvedValue({ data: { name: 'documents' }, error: null }),
|
||||
from: vi.fn().mockReturnValue({
|
||||
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
||||
download: vi.fn().mockResolvedValue({
|
||||
@@ -43,14 +45,16 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
createServiceClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
import { uploadDocument, createNewVersion, verifyIntegrity } from '../document-service'
|
||||
import { uploadDocument, createNewVersion, verifyIntegrity, _resetBucketVerified } from '../document-service'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
_resetBucketVerified()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
// Reset the mock to use default makeClient
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadSource } from '@/types'
|
||||
|
||||
@@ -10,6 +10,33 @@ import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadS
|
||||
* for documents linked to committed entries.
|
||||
*/
|
||||
|
||||
let bucketVerified = false
|
||||
|
||||
/** @internal Reset bucket verification flag — for testing only */
|
||||
export function _resetBucketVerified() {
|
||||
bucketVerified = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the 'documents' storage bucket exists, creating it if missing.
|
||||
* Runs once per process lifetime (same pattern as ensureInitialized).
|
||||
*/
|
||||
async function ensureDocumentsBucket(): Promise<void> {
|
||||
if (bucketVerified) return
|
||||
|
||||
const supabase = await createServiceClient()
|
||||
const { data: bucket } = await supabase.storage.getBucket('documents')
|
||||
|
||||
if (!bucket) {
|
||||
await supabase.storage.createBucket('documents', {
|
||||
public: false,
|
||||
fileSizeLimit: 52428800, // 50 MB
|
||||
})
|
||||
}
|
||||
|
||||
bucketVerified = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of a file buffer
|
||||
*/
|
||||
@@ -31,6 +58,7 @@ export async function uploadDocument(
|
||||
journal_entry_line_id?: string
|
||||
} = {}
|
||||
): Promise<DocumentAttachment> {
|
||||
await ensureDocumentsBucket()
|
||||
const supabase = await createClient()
|
||||
|
||||
// Compute SHA-256 hash
|
||||
@@ -102,6 +130,7 @@ export async function createNewVersion(
|
||||
originalId: string,
|
||||
file: { name: string; buffer: ArrayBuffer; type?: string }
|
||||
): Promise<DocumentAttachment> {
|
||||
await ensureDocumentsBucket()
|
||||
const supabase = await createClient()
|
||||
|
||||
// Compute SHA-256 hash
|
||||
|
||||
@@ -62,6 +62,11 @@ export function useExtensionToggle(sectorSlug: string, extensionSlug: string) {
|
||||
})
|
||||
if (!res.ok) {
|
||||
setEnabled(!newValue) // Revert on error
|
||||
} else {
|
||||
// Notify other components about the toggle change
|
||||
window.dispatchEvent(new CustomEvent('extension-toggle-changed', {
|
||||
detail: { sectorSlug, extensionSlug, enabled: newValue },
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
setEnabled(!newValue) // Revert on error
|
||||
|
||||
@@ -440,7 +440,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
))
|
||||
) : (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? 25}%):</Text>
|
||||
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 25)}%):</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -118,14 +118,14 @@ export function getVatRules(
|
||||
* Calculate VAT amount
|
||||
*/
|
||||
export function calculateVat(subtotal: number, vatRate: number): number {
|
||||
return subtotal * (vatRate / 100)
|
||||
return Math.round(subtotal * vatRate) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total including VAT
|
||||
*/
|
||||
export function calculateTotal(subtotal: number, vatRate: number): number {
|
||||
return subtotal + calculateVat(subtotal, vatRate)
|
||||
return Math.round((subtotal + calculateVat(subtotal, vatRate)) * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,6 +153,36 @@ export function getVatTreatmentLabel(treatment: VatTreatment): string {
|
||||
return labels[treatment]
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a display-friendly VAT summary from invoice line items.
|
||||
*
|
||||
* - If all items share a single rate → returns that rate's label and treatment
|
||||
* - If items have mixed rates → returns "Blandade momssatser" with null rate/treatment
|
||||
*/
|
||||
export function getVatSummaryFromItems(
|
||||
items: { vat_rate?: number | null }[]
|
||||
): { label: string; treatment: VatTreatment | null; rate: number | null; isMixed: boolean } {
|
||||
const rates = new Set(items.map((item) => item.vat_rate ?? 25))
|
||||
|
||||
if (rates.size === 1) {
|
||||
const rate = rates.values().next().value!
|
||||
const treatment = getVatTreatmentForRate(rate)
|
||||
return {
|
||||
label: getVatTreatmentLabel(treatment),
|
||||
treatment,
|
||||
rate,
|
||||
isMixed: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Blandade momssatser',
|
||||
treatment: null,
|
||||
rate: null,
|
||||
isMixed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get moms ruta description
|
||||
*/
|
||||
|
||||
@@ -159,9 +159,14 @@ describe('generateBalanceSheet', () => {
|
||||
const report = await generateBalanceSheet('user-1', 'period-1')
|
||||
|
||||
expect(report.asset_sections).toHaveLength(1) // Only 1930
|
||||
expect(report.equity_liability_sections).toEqual([])
|
||||
// Class 3-8 accounts are not included as balance sheet rows, but their
|
||||
// net result (credit - debit = 40000 + 500 - 8000 = 32500) appears as
|
||||
// "Årets resultat" in equity so the balance sheet can balance.
|
||||
expect(report.equity_liability_sections).toHaveLength(1)
|
||||
expect(report.equity_liability_sections[0].title).toBe('Årets resultat')
|
||||
expect(report.equity_liability_sections[0].subtotal).toBe(32500)
|
||||
expect(report.total_assets).toBe(50000)
|
||||
expect(report.total_equity_liabilities).toBe(0)
|
||||
expect(report.total_equity_liabilities).toBe(32500)
|
||||
})
|
||||
|
||||
it('uses Math.round for monetary precision on subtotals', async () => {
|
||||
|
||||
@@ -105,26 +105,26 @@ describe('generateMonthlyBreakdown', () => {
|
||||
data: [
|
||||
{
|
||||
account_number: '3001',
|
||||
debit: 0,
|
||||
credit: 10000,
|
||||
debit_amount: 0,
|
||||
credit_amount: 10000,
|
||||
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '5010',
|
||||
debit: 3000,
|
||||
credit: 0,
|
||||
debit_amount: 3000,
|
||||
credit_amount: 0,
|
||||
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '3001',
|
||||
debit: 0,
|
||||
credit: 5000,
|
||||
debit_amount: 0,
|
||||
credit_amount: 5000,
|
||||
journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '6200',
|
||||
debit: 1500,
|
||||
credit: 0,
|
||||
debit_amount: 1500,
|
||||
credit_amount: 0,
|
||||
journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
],
|
||||
@@ -156,7 +156,7 @@ describe('generateMonthlyBreakdown', () => {
|
||||
expect(mar.expenses).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores non-revenue/expense accounts (class 1, 2, 8)', async () => {
|
||||
it('ignores balance sheet accounts (class 1, 2) but includes class 8 financial items', async () => {
|
||||
let callCount = 0
|
||||
supabase.from.mockImplementation(() => {
|
||||
callCount++
|
||||
@@ -184,22 +184,28 @@ describe('generateMonthlyBreakdown', () => {
|
||||
data: [
|
||||
{
|
||||
account_number: '1930',
|
||||
debit: 10000,
|
||||
credit: 0,
|
||||
debit_amount: 10000,
|
||||
credit_amount: 0,
|
||||
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '2611',
|
||||
debit: 0,
|
||||
credit: 2500,
|
||||
debit_amount: 0,
|
||||
credit_amount: 2500,
|
||||
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '8999',
|
||||
debit: 500,
|
||||
credit: 0,
|
||||
account_number: '8400',
|
||||
debit_amount: 500,
|
||||
credit_amount: 0,
|
||||
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '8300',
|
||||
debit_amount: 0,
|
||||
credit_amount: 200,
|
||||
journal_entry: { entry_date: '2024-01-25', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
@@ -211,7 +217,10 @@ describe('generateMonthlyBreakdown', () => {
|
||||
|
||||
const result = await generateMonthlyBreakdown('user-1', 'period-1')
|
||||
const jan = result.months.find((m) => m.label === 'Jan')!
|
||||
expect(jan.income).toBe(0)
|
||||
expect(jan.expenses).toBe(0)
|
||||
// Class 1 and 2 are ignored
|
||||
// Class 8 debit (8400 interest expense) → expense
|
||||
expect(jan.expenses).toBe(500)
|
||||
// Class 8 credit (8300 interest income) → income
|
||||
expect(jan.income).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface ARReconciliationResult {
|
||||
*/
|
||||
export async function generateARReconciliation(
|
||||
userId: string,
|
||||
_periodId: string
|
||||
periodId: string
|
||||
): Promise<ARReconciliationResult> {
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -25,20 +25,31 @@ export async function generateARReconciliation(
|
||||
.in('status', ['sent', 'overdue'])
|
||||
|
||||
const arLedgerTotal = (invoices || [])
|
||||
.reduce((sum, inv) => sum + ((Number(inv.total) || 0) - (Number(inv.paid_amount) || 0)), 0)
|
||||
.reduce((sum, inv) => Math.round((sum + (Number(inv.total) || 0) - (Number(inv.paid_amount) || 0)) * 100) / 100, 0)
|
||||
|
||||
// Get account 1510 balance from journal entry lines
|
||||
// Get account 1510 balance from posted journal entry lines in this period
|
||||
const { data: journalLines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, journal_entry_id')
|
||||
.select(`
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entry:journal_entries!inner(
|
||||
status,
|
||||
user_id,
|
||||
fiscal_period_id
|
||||
)
|
||||
`)
|
||||
.eq('account_number', '1510')
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.fiscal_period_id', periodId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
|
||||
// Account 1510 is an asset: debit normal balance
|
||||
// Balance = debits - credits
|
||||
let account1510Balance = 0
|
||||
if (journalLines) {
|
||||
for (const line of journalLines) {
|
||||
account1510Balance += (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
|
||||
account1510Balance = Math.round((account1510Balance + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)) * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function generateGeneralLedger(
|
||||
.select('id, entry_date, voucher_number, voucher_series, description, source_type')
|
||||
.eq('user_id', userId)
|
||||
.eq('fiscal_period_id', periodId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.eq('status', 'posted')
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
|
||||
@@ -95,7 +95,7 @@ export async function generateGeneralLedger(
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.eq('status', 'posted')
|
||||
.lt('entry_date', period.period_start)
|
||||
|
||||
const openingBalances = new Map<string, number>()
|
||||
|
||||
@@ -46,8 +46,8 @@ export async function generateMonthlyBreakdown(
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
account_number,
|
||||
debit,
|
||||
credit,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entry:journal_entries!inner(
|
||||
entry_date,
|
||||
status,
|
||||
@@ -63,17 +63,21 @@ export async function generateMonthlyBreakdown(
|
||||
return { months: [] }
|
||||
}
|
||||
|
||||
// Build monthly aggregates
|
||||
const monthMap = new Map<number, { income: number; expenses: number }>()
|
||||
// Build monthly aggregates using year-aware keys ("2024-03", "2024-04", etc.)
|
||||
// to avoid data corruption for non-calendar fiscal years (e.g., Apr-Mar)
|
||||
const monthMap = new Map<string, { year: number; month: number; income: number; expenses: number }>()
|
||||
|
||||
// Initialize all months in the period range
|
||||
const startDate = new Date(period.period_start)
|
||||
const endDate = new Date(period.period_end)
|
||||
const startMonth = startDate.getMonth()
|
||||
const endMonth = endDate.getMonth() + (endDate.getFullYear() - startDate.getFullYear()) * 12
|
||||
|
||||
for (let m = startMonth; m <= endMonth; m++) {
|
||||
monthMap.set(m % 12, { income: 0, expenses: 0 })
|
||||
for (
|
||||
let y = startDate.getFullYear(), m = startDate.getMonth();
|
||||
y < endDate.getFullYear() || (y === endDate.getFullYear() && m <= endDate.getMonth());
|
||||
m === 11 ? (y++, m = 0) : m++
|
||||
) {
|
||||
const key = `${y}-${String(m).padStart(2, '0')}`
|
||||
monthMap.set(key, { year: y, month: m, income: 0, expenses: 0 })
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -85,35 +89,39 @@ export async function generateMonthlyBreakdown(
|
||||
}
|
||||
const accountClass = parseInt(line.account_number.charAt(0))
|
||||
const entryDate = new Date(entry.entry_date)
|
||||
const month = entryDate.getMonth()
|
||||
const key = `${entryDate.getFullYear()}-${String(entryDate.getMonth()).padStart(2, '0')}`
|
||||
|
||||
if (!monthMap.has(month)) {
|
||||
monthMap.set(month, { income: 0, expenses: 0 })
|
||||
if (!monthMap.has(key)) {
|
||||
monthMap.set(key, { year: entryDate.getFullYear(), month: entryDate.getMonth(), income: 0, expenses: 0 })
|
||||
}
|
||||
|
||||
const bucket = monthMap.get(month)!
|
||||
const bucket = monthMap.get(key)!
|
||||
|
||||
if (accountClass === 3) {
|
||||
// Revenue accounts: credit side represents revenue
|
||||
bucket.income = Math.round((bucket.income + line.credit - line.debit) * 100) / 100
|
||||
bucket.income = Math.round((bucket.income + line.credit_amount - line.debit_amount) * 100) / 100
|
||||
} else if (accountClass >= 4 && accountClass <= 7) {
|
||||
// Expense accounts: debit side represents expenses
|
||||
bucket.expenses = Math.round((bucket.expenses + line.debit - line.credit) * 100) / 100
|
||||
bucket.expenses = Math.round((bucket.expenses + line.debit_amount - line.credit_amount) * 100) / 100
|
||||
} else if (accountClass === 8) {
|
||||
// Financial items (class 8): interest, exchange gains/losses, etc.
|
||||
const amount = line.credit_amount - line.debit_amount
|
||||
if (amount >= 0) {
|
||||
bucket.income = Math.round((bucket.income + amount) * 100) / 100
|
||||
} else {
|
||||
bucket.expenses = Math.round((bucket.expenses + Math.abs(amount)) * 100) / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted array
|
||||
// Convert to sorted array (keys sort naturally as "YYYY-MM")
|
||||
const months: MonthlyBreakdownMonth[] = []
|
||||
const sortedMonths = Array.from(monthMap.entries()).sort((a, b) => {
|
||||
// Handle year boundaries (e.g., Nov-Dec-Jan for broken fiscal year)
|
||||
const aAdj = a[0] < startMonth ? a[0] + 12 : a[0]
|
||||
const bAdj = b[0] < startMonth ? b[0] + 12 : b[0]
|
||||
return aAdj - bAdj
|
||||
})
|
||||
const sortedKeys = Array.from(monthMap.keys()).sort()
|
||||
|
||||
for (const [month, data] of sortedMonths) {
|
||||
for (const key of sortedKeys) {
|
||||
const data = monthMap.get(key)!
|
||||
months.push({
|
||||
label: MONTH_LABELS[month],
|
||||
label: MONTH_LABELS[data.month],
|
||||
income: data.income,
|
||||
expenses: data.expenses,
|
||||
net: Math.round((data.income - data.expenses) * 100) / 100,
|
||||
|
||||
@@ -195,7 +195,8 @@ function dateStringToSIE(dateStr: string): string {
|
||||
* Format amount for SIE (no thousands separator, . as decimal)
|
||||
*/
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toFixed(2)
|
||||
const rounded = Math.round(amount * 100) / 100
|
||||
return rounded.toFixed(2)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,7 +219,7 @@ function calculateBalances(
|
||||
for (const line of lines) {
|
||||
const current = balances.get(line.account_number) || 0
|
||||
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
|
||||
balances.set(line.account_number, current + netAmount)
|
||||
balances.set(line.account_number, Math.round((current + netAmount) * 100) / 100)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,21 +24,31 @@ export async function generateReconciliation(
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
|
||||
const supplierLedgerTotal = (invoices || [])
|
||||
.reduce((sum, inv) => sum + (inv.remaining_amount || 0), 0)
|
||||
.reduce((sum, inv) => Math.round((sum + (inv.remaining_amount || 0)) * 100) / 100, 0)
|
||||
|
||||
// Get account 2440 balance from journal entry lines
|
||||
// Get account 2440 balance from posted journal entry lines in this period
|
||||
const { data: journalLines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, journal_entry_id')
|
||||
.select(`
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entry:journal_entries!inner(
|
||||
status,
|
||||
user_id,
|
||||
fiscal_period_id
|
||||
)
|
||||
`)
|
||||
.eq('account_number', '2440')
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.fiscal_period_id', periodId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
|
||||
// Filter to posted entries in the period
|
||||
// Account 2440 is a liability: credit normal balance
|
||||
// Balance = credits - debits
|
||||
let account2440Balance = 0
|
||||
if (journalLines) {
|
||||
// Account 2440 is a liability: credit normal balance
|
||||
// Balance = credits - debits
|
||||
for (const line of journalLines) {
|
||||
account2440Balance += (line.credit_amount || 0) - (line.debit_amount || 0)
|
||||
account2440Balance = Math.round((account2440Balance + (line.credit_amount || 0) - (line.debit_amount || 0)) * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.72.1",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
-- Migration 033: AI Chat Schema
|
||||
-- Creates tables for the AI chat assistant extension:
|
||||
-- chat_sessions, chat_messages, knowledge_documents, and match_documents RPC
|
||||
|
||||
-- Enable pgvector for embedding storage
|
||||
create extension if not exists vector with schema extensions;
|
||||
|
||||
-- ============================================================
|
||||
-- chat_sessions
|
||||
-- ============================================================
|
||||
|
||||
create table public.chat_sessions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid references auth.users on delete cascade not null,
|
||||
title text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.chat_sessions enable row level security;
|
||||
|
||||
create policy "chat_sessions_select" on public.chat_sessions
|
||||
for select using (auth.uid() = user_id);
|
||||
create policy "chat_sessions_insert" on public.chat_sessions
|
||||
for insert with check (auth.uid() = user_id);
|
||||
create policy "chat_sessions_update" on public.chat_sessions
|
||||
for update using (auth.uid() = user_id);
|
||||
create policy "chat_sessions_delete" on public.chat_sessions
|
||||
for delete using (auth.uid() = user_id);
|
||||
|
||||
create index idx_chat_sessions_user_created on public.chat_sessions (user_id, created_at desc);
|
||||
|
||||
create trigger chat_sessions_updated_at
|
||||
before update on public.chat_sessions
|
||||
for each row execute function public.update_updated_at_column();
|
||||
|
||||
-- ============================================================
|
||||
-- chat_messages
|
||||
-- ============================================================
|
||||
|
||||
create table public.chat_messages (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
session_id uuid references public.chat_sessions on delete cascade not null,
|
||||
user_id uuid references auth.users on delete cascade not null,
|
||||
role text not null check (role in ('user', 'assistant')),
|
||||
content text not null,
|
||||
sources jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.chat_messages enable row level security;
|
||||
|
||||
create policy "chat_messages_select" on public.chat_messages
|
||||
for select using (auth.uid() = user_id);
|
||||
create policy "chat_messages_insert" on public.chat_messages
|
||||
for insert with check (auth.uid() = user_id);
|
||||
create policy "chat_messages_update" on public.chat_messages
|
||||
for update using (auth.uid() = user_id);
|
||||
create policy "chat_messages_delete" on public.chat_messages
|
||||
for delete using (auth.uid() = user_id);
|
||||
|
||||
create index idx_chat_messages_session on public.chat_messages (session_id, created_at);
|
||||
|
||||
-- ============================================================
|
||||
-- knowledge_documents
|
||||
-- ============================================================
|
||||
|
||||
create table public.knowledge_documents (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
source_file text not null,
|
||||
title text not null,
|
||||
section_title text,
|
||||
content text not null,
|
||||
content_hash text unique not null,
|
||||
embedding extensions.vector(1536),
|
||||
metadata jsonb default '{}',
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.knowledge_documents enable row level security;
|
||||
|
||||
-- Knowledge documents are shared — any authenticated user can read
|
||||
create policy "knowledge_documents_select" on public.knowledge_documents
|
||||
for select using (true);
|
||||
|
||||
create index idx_knowledge_documents_hash on public.knowledge_documents (content_hash);
|
||||
|
||||
-- ============================================================
|
||||
-- match_documents RPC (vector similarity search)
|
||||
-- ============================================================
|
||||
|
||||
create or replace function public.match_documents(
|
||||
query_embedding extensions.vector,
|
||||
match_count int default 5,
|
||||
match_threshold float default 0.7
|
||||
)
|
||||
returns table (
|
||||
id uuid,
|
||||
source_file text,
|
||||
title text,
|
||||
section_title text,
|
||||
content text,
|
||||
metadata jsonb,
|
||||
similarity float
|
||||
)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, extensions
|
||||
as $$
|
||||
begin
|
||||
return query
|
||||
select
|
||||
kd.id,
|
||||
kd.source_file,
|
||||
kd.title,
|
||||
kd.section_title,
|
||||
kd.content,
|
||||
kd.metadata,
|
||||
1 - (kd.embedding <=> query_embedding)::float as similarity
|
||||
from public.knowledge_documents kd
|
||||
where 1 - (kd.embedding <=> query_embedding) >= match_threshold
|
||||
order by kd.embedding <=> query_embedding
|
||||
limit match_count;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.match_documents(extensions.vector, int, float) to authenticated;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Migration 034: Fix extension_data updated_at trigger
|
||||
-- The original trigger references update_updated_at() which does not exist.
|
||||
-- The correct function is public.update_updated_at_column().
|
||||
-- Wrapped in DO block in case extension_data table does not yet exist.
|
||||
|
||||
do $$
|
||||
begin
|
||||
if exists (
|
||||
select 1 from information_schema.tables
|
||||
where table_schema = 'public' and table_name = 'extension_data'
|
||||
) then
|
||||
drop trigger if exists extension_data_updated_at on public.extension_data;
|
||||
|
||||
create trigger extension_data_updated_at
|
||||
before update on public.extension_data
|
||||
for each row execute function public.update_updated_at_column();
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Migration 035: Fix push notifications
|
||||
-- 1. Expand notification_log notification_type CHECK constraint to include new event types
|
||||
-- 2. Add per-event enabled columns to notification_settings
|
||||
|
||||
-- Drop and recreate the CHECK constraint with new types
|
||||
alter table public.notification_log
|
||||
drop constraint if exists notification_log_notification_type_check;
|
||||
|
||||
alter table public.notification_log
|
||||
add constraint notification_log_notification_type_check
|
||||
check (notification_type in (
|
||||
'tax_deadline',
|
||||
'invoice_due',
|
||||
'invoice_overdue',
|
||||
'period_locked',
|
||||
'period_year_closed',
|
||||
'invoice_sent',
|
||||
'receipt_extracted',
|
||||
'receipt_matched'
|
||||
));
|
||||
|
||||
-- Add new per-event enabled columns to notification_settings
|
||||
alter table public.notification_settings
|
||||
add column if not exists period_locked_enabled boolean default true,
|
||||
add column if not exists period_year_closed_enabled boolean default true,
|
||||
add column if not exists invoice_sent_enabled boolean default false,
|
||||
add column if not exists receipt_extracted_enabled boolean default true,
|
||||
add column if not exists receipt_matched_enabled boolean default true;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Migration 036: Fix Enable Banking
|
||||
-- Add authorization_id column to bank_connections for PSD2 authorization tracking
|
||||
|
||||
alter table public.bank_connections
|
||||
add column if not exists authorization_id text;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Migration 038: Fix match_documents search_path
|
||||
-- The function needs the extensions schema in search_path to use pgvector operators.
|
||||
|
||||
create or replace function public.match_documents(
|
||||
query_embedding extensions.vector,
|
||||
match_count int default 5,
|
||||
match_threshold float default 0.7
|
||||
)
|
||||
returns table (
|
||||
id uuid,
|
||||
source_file text,
|
||||
title text,
|
||||
section_title text,
|
||||
content text,
|
||||
metadata jsonb,
|
||||
similarity float
|
||||
)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, extensions
|
||||
as $$
|
||||
begin
|
||||
return query
|
||||
select
|
||||
kd.id,
|
||||
kd.source_file,
|
||||
kd.title,
|
||||
kd.section_title,
|
||||
kd.content,
|
||||
kd.metadata,
|
||||
1 - (kd.embedding <=> query_embedding)::float as similarity
|
||||
from public.knowledge_documents kd
|
||||
where 1 - (kd.embedding <=> query_embedding) >= match_threshold
|
||||
order by kd.embedding <=> query_embedding
|
||||
limit match_count;
|
||||
end;
|
||||
$$;
|
||||
Reference in New Issue
Block a user