Fix/user feedback (#210)

* Add delete policies for provider consent tokens and provider OTC

* Add trade name support for companies in settings and documents

* Resolved currency selection issue

* Enhance invoice line display with foreign currency support and update delivery date schema to allow empty values

* Add currency display for journal entries and include currency metadata in transaction creation

* Add trade_name column to company_settings for external display
This commit is contained in:
Mattsson
2026-04-09 16:22:51 +02:00
committed by GitHub
parent b484e9a7b4
commit bb336eba88
31 changed files with 452 additions and 87 deletions
+67 -33
View File
@@ -211,22 +211,39 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
<td className="py-2 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-2 text-right tabular-nums">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
<td className="py-2 text-right tabular-nums">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
</tr>
))}
{lines.map((line) => {
const hasForeignCurrency = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null
return (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
<td className="py-2 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-2 text-right tabular-nums">
{Number(line.debit_amount) > 0 && (
<>
{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{hasForeignCurrency && Number(line.debit_amount) > 0 && (
<span className="block text-xs text-muted-foreground">
{Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
</span>
)}
</>
)}
</td>
<td className="py-2 text-right tabular-nums">
{Number(line.credit_amount) > 0 && (
<>
{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{hasForeignCurrency && Number(line.credit_amount) > 0 && (
<span className="block text-xs text-muted-foreground">
{Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
</span>
)}
</>
)}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr className="font-semibold">
@@ -244,24 +261,41 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{/* Mobile cards */}
<div className="sm:hidden space-y-2">
{lines.map((line) => (
<div key={line.id} className="flex items-center justify-between py-2 border-b last:border-0 gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm"><AccountNumber number={line.account_number} showName /></div>
{line.line_description && (
<p className="text-xs text-muted-foreground truncate">{line.line_description}</p>
)}
{lines.map((line) => {
const hasForeignCurrency = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null
return (
<div key={line.id} className="flex items-center justify-between py-2 border-b last:border-0 gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm"><AccountNumber number={line.account_number} showName /></div>
{line.line_description && (
<p className="text-xs text-muted-foreground truncate">{line.line_description}</p>
)}
</div>
<div className="text-right shrink-0 text-sm tabular-nums">
{Number(line.debit_amount) > 0 && (
<p>
{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D
{hasForeignCurrency && (
<span className="block text-xs text-muted-foreground">
{Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
</span>
)}
</p>
)}
{Number(line.credit_amount) > 0 && (
<p>
{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K
{hasForeignCurrency && (
<span className="block text-xs text-muted-foreground">
{Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
</span>
)}
</p>
)}
</div>
</div>
<div className="text-right shrink-0 text-sm tabular-nums">
{Number(line.debit_amount) > 0 && (
<p>{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D</p>
)}
{Number(line.credit_amount) > 0 && (
<p>{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K</p>
)}
</div>
</div>
))}
)
})}
<div className="flex justify-between font-semibold text-sm pt-1">
<span>Summa</span>
<div className="flex gap-3 tabular-nums">
@@ -16,6 +16,7 @@ export default function CompanySettingsPage() {
function handleSave(formData: FormData) {
const updates: Record<string, unknown> = {
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
trade_name: (formData.get('trade_name') as string) || null,
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
address_line1: formData.get('address_line1') as string,
postal_code: formData.get('postal_code') as string,
+39 -1
View File
@@ -9,6 +9,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { X } from 'lucide-react'
import TransactionForm from '@/components/transactions/TransactionForm'
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
@@ -88,6 +89,7 @@ export default function TransactionsPage() {
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
const { toast } = useToast()
const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm()
const supabase = createClient()
// Computed lists
@@ -508,6 +510,40 @@ export default function TransactionsPage() {
setIsCreating(false)
}
async function handleDeleteTransaction(id: string) {
const transaction = transactions.find((t) => t.id === id)
if (!transaction) return
const ok = await confirmDelete({
title: 'Ta bort transaktion',
description: `Är du säker på att du vill ta bort "${transaction.description}"? Åtgärden kan inte ångras.`,
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
try {
const response = await fetch(`/api/transactions/${id}`, { method: 'DELETE' })
if (!response.ok) {
const result = await response.json()
toast({
title: 'Kunde inte ta bort',
description: result.error || 'Försök igen.',
variant: 'destructive',
})
return
}
setTransactions((prev) => prev.filter((t) => t.id !== id))
toast({ title: 'Borttagen', description: 'Transaktionen har tagits bort' })
} catch {
toast({
title: 'Kunde inte ta bort',
description: 'Transaktionen kunde inte tas bort. Försök igen.',
variant: 'destructive',
})
}
}
function handleTransactionBooked(transactionId: string, journalEntryId: string) {
setExitingIds((prev) => new Set(prev).add(transactionId))
setTimeout(() => {
@@ -786,7 +822,7 @@ export default function TransactionsPage() {
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
@@ -917,6 +953,8 @@ export default function TransactionsPage() {
<TransactionForm onSubmit={handleCreateTransaction} isLoading={isCreating} />
</DialogContent>
</Dialog>
<DestructiveConfirmDialog {...deleteDialogProps} />
</div>
)
}
+1 -1
View File
@@ -141,7 +141,7 @@ export async function POST(
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name,
fromName: company.trade_name || company.company_name,
attachments: [
{
filename,
+1
View File
@@ -211,6 +211,7 @@ export async function POST(request: Request) {
.single()
if (invoiceError) {
console.error('Invoice insert error:', invoiceError)
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
}
+2 -2
View File
@@ -94,11 +94,11 @@ export async function GET(request: Request) {
// Get company name for the consent page
const { data: settings } = await supabase
.from('company_settings')
.select('company_name')
.select('company_name, trade_name')
.eq('company_id', companyId)
.single()
const companyName = settings?.company_name || user.email
const companyName = settings?.trade_name || settings?.company_name || user.email
// Render consent page
const html = `<!DOCTYPE html>
@@ -559,7 +559,7 @@ async function commitSendInvoice(
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name,
fromName: company.trade_name || company.company_name,
attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }],
})
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeTransaction,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { DELETE } from '../route'
describe('DELETE /api/transactions/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when transaction not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(404)
expect(body).toEqual({ error: 'Transaction not found' })
})
it('returns 409 when transaction has a journal entry', async () => {
const tx = makeTransaction({ journal_entry_id: 'je-1', bank_connection_id: null, import_source: null })
enqueue({ data: tx, error: null })
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('booked')
})
it('returns 409 when transaction is bank-synced', async () => {
const tx = makeTransaction({ bank_connection_id: 'bc-1', journal_entry_id: null, import_source: null })
enqueue({ data: tx, error: null })
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('bank-synced')
})
it('returns 409 when transaction was imported', async () => {
const tx = makeTransaction({ import_source: 'csv_nordea', journal_entry_id: null, bank_connection_id: null })
enqueue({ data: tx, error: null })
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('imported')
})
it('deletes a manually added unbooked transaction', async () => {
const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: null })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // delete
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(body).toEqual({ success: true })
})
it('returns 500 when deletion fails', async () => {
const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: null })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: { message: 'DB error' } }) // delete fails
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(500)
expect(body).toEqual({ error: 'Failed to delete transaction' })
})
})
+62
View File
@@ -0,0 +1,62 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function DELETE(
_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 companyId = await requireCompanyId(supabase, user.id)
// Fetch the transaction with ownership check
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id, bank_connection_id, import_source')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Guard: only manually added, unbooked transactions can be deleted
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Cannot delete a booked transaction. Use reversal (storno) instead.' },
{ status: 409 }
)
}
if (transaction.bank_connection_id) {
return NextResponse.json(
{ error: 'Cannot delete a bank-synced transaction' },
{ status: 409 }
)
}
if (transaction.import_source) {
return NextResponse.json(
{ error: 'Cannot delete an imported transaction' },
{ status: 409 }
)
}
const { error: deleteError } = await supabase
.from('transactions')
.delete()
.eq('id', id)
.eq('company_id', companyId)
if (deleteError) {
return NextResponse.json({ error: 'Failed to delete transaction' }, { status: 500 })
}
return NextResponse.json({ success: true })
}