feat: open-source under AGPL-3.0, redesign UI to grayscale palette, add uncategorize API, fix VAT account names

Add LICENSE (AGPL-3.0-or-later), CONTRIBUTING.md, SECURITY.md, DCO, and NOTICE files.
Rewrite README for open-source audience with self-hosting instructions.
Redesign color palette to grayscale chrome theme across all components.
Add transaction uncategorize API route with tests.
Fix VAT account name mismatches in migration 052.
Improve import page with SIE file support and loading skeleton.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-05 18:18:11 +01:00
co-authored by Claude Opus 4.6
parent 29240738fa
commit f3ec634a46
58 changed files with 1662 additions and 218 deletions
+4 -2
View File
@@ -129,9 +129,11 @@ export async function POST(
filename = `faktura-${invoice.invoice_number}.pdf`
}
// Send email
// Send email (CC the user so they have a copy of what was sent)
const ccAddress = company.email || user.email
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
@@ -215,7 +217,7 @@ export async function POST(
return NextResponse.json({
success: true,
message: `Fakturan har skickats till ${customer.email}`,
message: `Fakturan har skickats till ${customer.email} (kopia till ${ccAddress})`,
messageId: result.messageId
})
} catch (error) {
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} 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 mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
import { POST } from '../route'
describe('POST /api/transactions/[id]/uncategorize', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
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/uncategorize', { method: 'POST' })
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 404 when transaction not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/transactions/tx-999/uncategorize', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(404)
expect(body).toEqual({ error: 'Transaction not found' })
})
it('returns 400 when transaction has no journal entry', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null })
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(body).toEqual({ error: 'Transaction has no journal entry' })
})
it('returns 400 when journal entry is not posted', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null })
enqueue({ data: { id: 'je-1', status: 'draft' }, error: null })
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(body).toEqual({ error: 'Journal entry is not posted' })
})
it('returns 500 when reversal fails', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null })
enqueue({ data: { id: 'je-1', status: 'posted' }, error: null })
mockReverseEntry.mockRejectedValue(new Error('Period is locked'))
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(500)
expect(body).toEqual({ error: 'Period is locked' })
})
it('returns 200 and reverses entry on success', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null })
enqueue({ data: { id: 'je-1', status: 'posted' }, error: null })
mockReverseEntry.mockResolvedValue({ id: 'je-reversal' })
enqueue({ data: { id: 'tx-1' }, error: null })
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(body).toEqual({ success: true })
expect(mockReverseEntry).toHaveBeenCalledWith(mockSupabase, 'user-1', 'je-1')
})
})
@@ -0,0 +1,76 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init'
ensureInitialized()
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
// Fetch transaction
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, journal_entry_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (txError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
if (!transaction.journal_entry_id) {
return NextResponse.json({ error: 'Transaction has no journal entry' }, { status: 400 })
}
// Verify journal entry is posted
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id, status')
.eq('id', transaction.journal_entry_id)
.eq('user_id', user.id)
.single()
if (entryError || !entry) {
return NextResponse.json({ error: 'Journal entry not found' }, { status: 400 })
}
if (entry.status !== 'posted') {
return NextResponse.json({ error: 'Journal entry is not posted' }, { status: 400 })
}
// Storno reversal (legally compliant — never deletes)
try {
await reverseEntry(supabase, user.id, transaction.journal_entry_id)
} catch (err) {
const message = err instanceof Error ? err.message : 'Reversal failed'
return NextResponse.json({ error: message }, { status: 500 })
}
// Reset transaction categorization
const { error: updateError } = await supabase
.from('transactions')
.update({
is_business: null,
category: null,
journal_entry_id: null,
})
.eq('id', id)
.eq('user_id', user.id)
if (updateError) {
return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 })
}
return NextResponse.json({ success: true })
}