refactor: clean up codebase, remove dead code and obsolete docs

Remove influencer-era documentation, unused components, boilerplate
assets, and ghost tiktok cron job. Add supplier invoice management,
document API routes, and PWA icons. Replace boilerplate README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-20 12:23:17 +01:00
co-authored by Claude Opus 4.6
parent e8743e6e03
commit 838dc6b8b5
113 changed files with 6289 additions and 19036 deletions
+8 -7
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSession, getAccountBalance, type AccountInfo } from '@/lib/banking/enable-banking'
@@ -35,7 +35,7 @@ export async function GET(request: Request) {
return NextResponse.redirect(`${baseUrl}/settings?bank_error=missing_parameters`)
}
const supabase = await createClient()
const supabase = await createServiceClient()
try {
// Create session from authorization code
@@ -88,16 +88,17 @@ export async function GET(request: Request) {
.from('bank_connections')
.insert({
user_id: state,
bank_id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
provider: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
bank_name: aspsp.name,
session_id,
status: 'active',
accounts: accountsWithBalances,
consent_expires_at: consentExpiresAt,
accounts_data: accountsWithBalances,
consent_expires: consentExpiresAt,
last_synced_at: new Date().toISOString(),
})
if (insertError) {
console.error('Insert error:', insertError)
throw new Error('Failed to create connection')
}
} else {
@@ -107,8 +108,8 @@ export async function GET(request: Request) {
.update({
session_id,
status: 'active',
accounts: accountsWithBalances,
consent_expires_at: consentExpiresAt,
accounts_data: accountsWithBalances,
consent_expires: consentExpiresAt,
last_synced_at: new Date().toISOString(),
})
.eq('id', pendingConnection.id)
+1 -1
View File
@@ -65,7 +65,7 @@ export async function POST(request: Request) {
.from('bank_connections')
.insert({
user_id: user.id,
bank_id: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
bank_name: aspsp_name,
authorization_id,
status: 'pending',
+4 -4
View File
@@ -72,7 +72,7 @@ export async function GET(request: Request) {
for (const connection of connections) {
try {
// Check consent expiry
const daysLeft = getDaysUntilExpiry(connection.consent_expires_at)
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
const isExpired = daysLeft !== null && daysLeft <= 0
if (isExpired) {
@@ -95,7 +95,7 @@ export async function GET(request: Request) {
continue
}
const expiringSoon = isConsentExpiringSoon(connection.consent_expires_at)
const expiringSoon = isConsentExpiringSoon(connection.consent_expires)
// Sync last 7 days (daily cron, with overlap for safety)
const toDate = new Date().toISOString().split('T')[0]
@@ -103,7 +103,7 @@ export async function GET(request: Request) {
.toISOString()
.split('T')[0]
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
let totalImported = 0
let totalDuplicates = 0
@@ -128,7 +128,7 @@ export async function GET(request: Request) {
await supabase
.from('bank_connections')
.update({
accounts,
accounts_data: accounts,
last_synced_at: new Date().toISOString(),
})
.eq('id', connection.id)
+2 -2
View File
@@ -43,7 +43,7 @@ export async function POST(request: Request) {
}
try {
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
@@ -72,7 +72,7 @@ export async function POST(request: Request) {
await supabase
.from('bank_connections')
.update({
accounts,
accounts_data: accounts,
last_synced_at: syncedAt,
})
.eq('id', connection.id)
+55
View File
@@ -0,0 +1,55 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
ensureInitialized()
/**
* POST /api/documents/:id/link
* Link a document to a journal entry (verifikation)
*
* Request body:
* - journal_entry_id: string (required)
* - journal_entry_line_id: string (optional)
*/
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
try {
const body = await request.json()
if (!body.journal_entry_id) {
return NextResponse.json(
{ error: 'journal_entry_id is required' },
{ status: 400 }
)
}
const document = await linkToJournalEntry(
user.id,
id,
body.journal_entry_id,
body.journal_entry_line_id
)
return NextResponse.json({ data: document })
} catch (error) {
console.error('[documents/link/POST] Link failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Link failed' },
{ status: 500 }
)
}
}
+55
View File
@@ -0,0 +1,55 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
ensureInitialized()
/**
* GET /api/documents/:id
* Fetch document metadata + signed download URL (60 min expiry)
*/
export async function GET(
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 document record
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Create signed download URL (60 minutes)
const { data: signedUrl, error: signError } = await supabase.storage
.from('documents')
.createSignedUrl(doc.storage_path, 3600)
if (signError) {
return NextResponse.json(
{ error: `Failed to create download URL: ${signError.message}` },
{ status: 500 }
)
}
return NextResponse.json({
data: {
...doc,
download_url: signedUrl.signedUrl,
},
})
}
+37
View File
@@ -0,0 +1,37 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { verifyIntegrity } from '@/lib/core/documents/document-service'
ensureInitialized()
/**
* POST /api/documents/:id/verify
* Verify document integrity by re-computing SHA-256 and comparing
*/
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
try {
const result = await verifyIntegrity(user.id, id)
return NextResponse.json({ data: result })
} catch (error) {
console.error('[documents/verify/POST] Verification failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Verification failed' },
{ status: 500 }
)
}
}
+101
View File
@@ -0,0 +1,101 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createNewVersion } from '@/lib/core/documents/document-service'
ensureInitialized()
/**
* POST /api/documents/:id/versions
* Create a new version of an existing document (atomic via RPC)
*
* Accepts multipart/form-data with:
* - file: The new version file
*/
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
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const buffer = await file.arrayBuffer()
const newVersion = await createNewVersion(user.id, id, {
name: file.name,
buffer,
type: file.type,
})
return NextResponse.json({ data: newVersion })
} catch (error) {
console.error('[documents/versions/POST] Version creation failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Version creation failed' },
{ status: 500 }
)
}
}
/**
* GET /api/documents/:id/versions
* List all versions in the document chain
*/
export async function GET(
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
// First, check if the document belongs to the user
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, original_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// The root document is either the original_id or the document itself
const rootId = doc.original_id || doc.id
// Fetch all versions in the chain
const { data: versions, error: versionsError } = await supabase
.from('document_attachments')
.select('*')
.eq('user_id', user.id)
.or(`id.eq.${rootId},original_id.eq.${rootId}`)
.order('version', { ascending: true })
if (versionsError) {
return NextResponse.json({ error: versionsError.message }, { status: 500 })
}
return NextResponse.json({ data: versions })
}
+108
View File
@@ -0,0 +1,108 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument } from '@/lib/core/documents/document-service'
ensureInitialized()
/**
* POST /api/documents
* Upload a document to the WORM archive
*
* Accepts multipart/form-data with:
* - file: The document file
* - upload_source (optional): 'camera' | 'file_upload' | 'email' | ...
* - journal_entry_id (optional): Link to a journal entry
* - journal_entry_line_id (optional): Link to a journal entry line
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const uploadSource = (formData.get('upload_source') as string) || 'file_upload'
const journalEntryId = formData.get('journal_entry_id') as string | null
const journalEntryLineId = formData.get('journal_entry_line_id') as string | null
const buffer = await file.arrayBuffer()
const document = await uploadDocument(user.id, {
name: file.name,
buffer,
type: file.type,
}, {
upload_source: uploadSource as import('@/types').DocumentUploadSource,
journal_entry_id: journalEntryId || undefined,
journal_entry_line_id: journalEntryLineId || undefined,
})
return NextResponse.json({ data: document })
} catch (error) {
console.error('[documents/POST] Upload failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Upload failed' },
{ status: 500 }
)
}
}
/**
* GET /api/documents
* List documents with optional filtering
*
* Query params:
* - journal_entry_id: Filter by journal entry
* - current_only: If 'true', only return current versions (default: true)
* - limit: Number of results (default: 50)
* - offset: Pagination offset (default: 0)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const journalEntryId = searchParams.get('journal_entry_id')
const currentOnly = searchParams.get('current_only') !== 'false'
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabase
.from('document_attachments')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (journalEntryId) {
query = query.eq('journal_entry_id', journalEntryId)
}
if (currentOnly) {
query = query.eq('is_current_version', true)
}
const { data, error, count } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data, count })
}
+117
View File
@@ -0,0 +1,117 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
/**
* GET /api/documents/verify/cron
* Batch integrity verification of WORM document archive
*
* Runs weekly (Sunday 03:00 UTC / 05:00 Swedish time).
* Processes up to 100 documents per run, prioritizing
* documents never checked or least recently checked.
*
* Uses service role for cross-user verification (RLS bypass).
*/
export async function GET(request: Request) {
// Verify cron secret
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500 }
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Fetch up to 100 current-version documents, prioritizing unchecked/oldest
const { data: documents, error: fetchError } = await supabase
.from('document_attachments')
.select('id, user_id, storage_path, sha256_hash, file_name')
.eq('is_current_version', true)
.order('last_integrity_check_at', { ascending: true, nullsFirst: true })
.limit(100)
if (fetchError) {
console.error('[doc-verify-cron] Failed to fetch documents:', fetchError)
return NextResponse.json({ error: 'Failed to fetch documents' }, { status: 500 })
}
if (!documents || documents.length === 0) {
return NextResponse.json({ message: 'No documents to verify', processed: 0 })
}
let verified = 0
let failures = 0
let errors = 0
for (const doc of documents) {
try {
// Download file from storage
const { data: fileData, error: downloadError } = await supabase.storage
.from('documents')
.download(doc.storage_path)
if (downloadError || !fileData) {
console.error(`[doc-verify-cron] Download failed for ${doc.id}:`, downloadError)
errors++
continue
}
// Compute SHA-256 hash
const buffer = await fileData.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const computedHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
const isValid = computedHash === doc.sha256_hash
// Update last_integrity_check_at
await supabase
.from('document_attachments')
.update({ last_integrity_check_at: new Date().toISOString() })
.eq('id', doc.id)
if (!isValid) {
// Log integrity failure to audit_log
await supabase.from('audit_log').insert({
user_id: doc.user_id,
action: 'INTEGRITY_FAILURE',
table_name: 'document_attachments',
record_id: doc.id,
description: `Integrity check failed for document "${doc.file_name}": stored hash ${doc.sha256_hash}, computed hash ${computedHash}`,
old_state: { sha256_hash: doc.sha256_hash },
new_state: { computed_hash: computedHash },
})
console.error(`[doc-verify-cron] INTEGRITY FAILURE: document ${doc.id} (${doc.file_name})`)
failures++
} else {
verified++
}
} catch (error) {
console.error(`[doc-verify-cron] Error verifying document ${doc.id}:`, error)
errors++
// Continue with other documents
}
}
console.log(
`[doc-verify-cron] Processed ${documents.length}: ${verified} verified, ${failures} failures, ${errors} errors`
)
return NextResponse.json({
processed: documents.length,
verified,
failures,
errors,
})
}
@@ -4,6 +4,7 @@ import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer'
import { processLineItems } from '@/extensions/receipt-ocr/lib/receipt-categorizer'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument } from '@/lib/core/documents/document-service'
ensureInitialized()
@@ -63,6 +64,17 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 })
}
// WORM archive copy (non-blocking — receipt flow continues even if this fails)
try {
await uploadDocument(user.id, {
name: imageFile.name,
buffer: arrayBuffer,
type: imageFile.type,
}, { upload_source: 'camera' })
} catch (archiveErr) {
console.error('[receipt-upload] WORM archive copy failed:', archiveErr)
}
// Get public URL
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
const imageUrl = urlData.publicUrl
+110
View File
@@ -0,0 +1,110 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import type { Invoice } from '@/types'
/**
* POST /api/invoices/[id]/mark-paid
*
* Manually marks an invoice as paid (for payments received outside bank sync).
*
* Faktureringsmetoden (accrual):
* Creates payment clearing entry: Debit 1930, Credit 1510
*
* Kontantmetoden (cash):
* Creates combined revenue entry: Debit 1930, Credit 30xx, Credit 26xx
*/
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 })
}
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return NextResponse.json(
{ error: 'Fakturan kan inte markeras som betald i nuvarande status' },
{ status: 400 }
)
}
const now = new Date().toISOString()
const paymentDate = now.split('T')[0]
// Update status to paid
const { error: updateError } = await supabase
.from('invoices')
.update({
status: 'paid',
paid_at: now,
paid_amount: invoice.total,
})
.eq('id', id)
.eq('user_id', user.id)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
}
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
let journalEntryId: string | null = null
try {
if (accountingMethod === 'accrual') {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
user.id,
invoice as Invoice,
paymentDate
)
journalEntryId = journalEntry?.id ?? null
} else {
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
const journalEntry = await createInvoiceCashEntry(
user.id,
invoice as Invoice,
paymentDate
)
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
console.error('Failed to create payment journal entry on mark-paid:', err)
}
return NextResponse.json({
success: true,
status: 'paid',
paid_at: now,
paid_amount: invoice.total,
journal_entry_id: journalEntryId,
})
}
+93
View File
@@ -0,0 +1,93 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import type { Invoice } from '@/types'
/**
* POST /api/invoices/[id]/mark-sent
*
* Manually marks a draft invoice as sent (for invoices delivered outside the system).
* Under faktureringsmetoden (accrual): creates the journal entry (Debit 1510, Credit 30xx/26xx).
* Under kontantmetoden (cash): no journal entry — booking happens at payment.
*/
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 })
}
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
}
if (invoice.status !== 'draft') {
return NextResponse.json(
{ error: 'Endast utkast kan markeras som skickade' },
{ status: 400 }
)
}
// Update status to sent
const { error: updateError } = await supabase
.from('invoices')
.update({
status: 'sent',
sent_at: new Date().toISOString(),
})
.eq('id', id)
.eq('user_id', user.id)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
}
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
// Faktureringsmetoden: book at send
let journalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
invoice as Invoice
)
if (journalEntry) {
journalEntryId = journalEntry.id
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
}
} catch (err) {
console.error('Failed to create invoice journal entry on mark-sent:', err)
}
}
return NextResponse.json({
success: true,
status: 'sent',
journal_entry_id: journalEntryId,
})
}
+20
View File
@@ -10,6 +10,7 @@ import {
generateInvoiceEmailText,
generateInvoiceEmailSubject
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
ensureInitialized()
@@ -158,6 +159,25 @@ export async function POST(
// Don't fail the request - the email was sent successfully
}
// Faktureringsmetoden: create journal entry when invoice is issued
if ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method) {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
invoice as Invoice
)
if (journalEntry) {
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
}
} catch (err) {
console.error('Failed to create invoice journal entry on send:', err)
// Non-blocking — don't fail the send
}
}
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, userId: user.id },
+1 -18
View File
@@ -6,7 +6,6 @@ import type { CreateInvoiceInput, Invoice, CreditNote } from '@/types'
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
createInvoiceJournalEntry,
createCreditNoteJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
@@ -176,24 +175,8 @@ export async function POST(request: Request) {
.eq('id', invoice.id)
.single()
// Create journal entry for the invoice (non-blocking)
// Emit event (no journal entry at draft — booking happens at send/payment based on accounting method)
if (completeInvoice) {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
completeInvoice as Invoice
)
if (journalEntry) {
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
}
} catch (err) {
console.error('Failed to create invoice journal entry:', err)
// Don't fail the invoice creation
}
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
+32
View File
@@ -0,0 +1,32 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
import { generateReconciliation } from '@/lib/reports/supplier-reconciliation'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const asOfDate = searchParams.get('as_of_date') || undefined
const periodId = searchParams.get('period_id') || undefined
const ledger = await generateSupplierLedger(user.id, asOfDate)
let reconciliation = null
if (periodId) {
reconciliation = await generateReconciliation(user.id, periodId)
}
return NextResponse.json({
data: {
ledger,
reconciliation,
},
})
}
@@ -0,0 +1,48 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
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 { data: invoice } = await supabase
.from('supplier_invoices')
.select('status')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (!invoice) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (invoice.status !== 'registered') {
return NextResponse.json(
{ error: 'Kan bara godkänna registrerade fakturor' },
{ status: 400 }
)
}
const { data, error } = await supabase
.from('supplier_invoices')
.update({ status: 'approved' })
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
@@ -0,0 +1,127 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
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 })
}
// Fetch original invoice with supplier and items
const { data: original, error: fetchError } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !original) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (original.status === 'credited') {
return NextResponse.json(
{ error: 'Fakturan har redan krediterats' },
{ status: 400 }
)
}
// Get next arrival number
const { data: arrivalNum } = await supabase
.rpc('get_next_arrival_number', { p_user_id: user.id })
// Create credit note invoice (negative amounts)
const { data: creditNote, error: creditError } = await supabase
.from('supplier_invoices')
.insert({
user_id: user.id,
supplier_id: original.supplier_id,
arrival_number: arrivalNum,
supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`,
invoice_date: new Date().toISOString().split('T')[0],
due_date: new Date().toISOString().split('T')[0],
status: 'registered',
currency: original.currency,
exchange_rate: original.exchange_rate,
vat_treatment: original.vat_treatment,
reverse_charge: original.reverse_charge,
subtotal: original.subtotal,
subtotal_sek: original.subtotal_sek,
vat_amount: original.vat_amount,
vat_amount_sek: original.vat_amount_sek,
total: original.total,
total_sek: original.total_sek,
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: id,
})
.select()
.single()
if (creditError || !creditNote) {
return NextResponse.json({ error: creditError?.message || 'Failed to create credit note' }, { status: 500 })
}
// Copy items to credit note
const creditItems = (original.items || []).map((item: SupplierInvoiceItem) => ({
supplier_invoice_id: creditNote.id,
sort_order: item.sort_order,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.line_total,
account_number: item.account_number,
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
}))
await supabase.from('supplier_invoice_items').insert(creditItems)
// Create credit note journal entry
let journalEntryId: string | null = null
try {
const journalEntry = await createSupplierCreditNoteEntry(
user.id,
creditNote as SupplierInvoice,
creditItems as SupplierInvoiceItem[],
original.supplier?.supplier_type || 'swedish_business'
)
if (journalEntry) {
journalEntryId = journalEntry.id
await supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', creditNote.id)
}
} catch (err) {
console.error('Failed to create credit note journal entry:', err)
}
// Update original invoice: reduce remaining_amount
const newRemaining = Math.max(0, original.remaining_amount - original.total)
const newStatus = newRemaining <= 0 ? 'credited' : original.status
await supabase
.from('supplier_invoices')
.update({
status: newStatus,
remaining_amount: newRemaining,
})
.eq('id', id)
return NextResponse.json({
data: creditNote,
journal_entry_id: journalEntryId,
})
}
@@ -0,0 +1,136 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
interface MarkPaidRequest {
amount?: number
payment_date?: string
exchange_rate_difference?: number
notes?: string
}
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: MarkPaidRequest = await request.json()
// Fetch invoice with supplier and items
const { data: invoice, error: fetchError } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !invoice) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (!['registered', 'approved', 'partially_paid', 'overdue'].includes(invoice.status)) {
return NextResponse.json(
{ error: 'Fakturan kan inte markeras som betald i nuvarande status' },
{ status: 400 }
)
}
const paymentDate = body.payment_date || new Date().toISOString().split('T')[0]
const paymentAmount = body.amount || invoice.remaining_amount
const now = new Date().toISOString()
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
// Create journal entry
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
const journalEntry = await createSupplierInvoiceCashEntry(
user.id,
invoice as SupplierInvoice,
(invoice.items || []) as SupplierInvoiceItem[],
paymentDate,
invoice.supplier?.supplier_type || 'swedish_business'
)
if (journalEntry) journalEntryId = journalEntry.id
} else {
const journalEntry = await createSupplierInvoicePaymentEntry(
user.id,
invoice as SupplierInvoice,
paymentAmount,
paymentDate,
body.exchange_rate_difference
)
if (journalEntry) journalEntryId = journalEntry.id
}
} catch (err) {
console.error('Failed to create payment journal entry:', err)
}
// Calculate new remaining amount
const newRemaining = Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
// Update invoice
const { error: updateError } = await supabase
.from('supplier_invoices')
.update({
status: newStatus,
remaining_amount: Math.max(0, newRemaining),
paid_amount: newPaidAmount,
paid_at: isFullyPaid ? now : null,
payment_journal_entry_id: journalEntryId,
})
.eq('id', id)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
// Record payment
const { error: paymentError } = await supabase
.from('supplier_invoice_payments')
.insert({
supplier_invoice_id: id,
payment_date: paymentDate,
amount: paymentAmount,
currency: invoice.currency,
exchange_rate_difference: body.exchange_rate_difference || 0,
journal_entry_id: journalEntryId,
notes: body.notes || null,
})
if (paymentError) {
console.error('Failed to record payment:', paymentError)
}
return NextResponse.json({
success: true,
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: Math.max(0, newRemaining),
journal_entry_id: journalEntryId,
})
}
+133
View File
@@ -0,0 +1,133 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(
_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 { data: invoice, error } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error || !invoice) {
return NextResponse.json({ error: 'Supplier invoice not found' }, { status: 404 })
}
return NextResponse.json({ data: invoice })
}
export async function PUT(
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 })
}
// Only allow editing registered invoices
const { data: existing } = await supabase
.from('supplier_invoices')
.select('status')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (existing.status !== 'registered') {
return NextResponse.json(
{ error: 'Kan bara redigera registrerade fakturor' },
{ status: 400 }
)
}
const body = await request.json()
const { data, error } = await supabase
.from('supplier_invoices')
.update({
supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date,
due_date: body.due_date,
delivery_date: body.delivery_date,
payment_reference: body.payment_reference,
notes: body.notes,
})
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
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 })
}
// Only allow deleting registered invoices without journal entries
const { data: existing } = await supabase
.from('supplier_invoices')
.select('status, registration_journal_entry_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (existing.status !== 'registered') {
return NextResponse.json(
{ error: 'Kan bara ta bort registrerade fakturor' },
{ status: 400 }
)
}
// Delete items first, then invoice
await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', id)
const { error } = await supabase
.from('supplier_invoices')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+186
View File
@@ -0,0 +1,186 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
let query = supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(id, name)')
.eq('user_id', user.id)
if (status && status !== 'all') {
if (status === 'to_pay') {
query = query.in('status', ['approved', 'overdue'])
} else {
query = query.eq('status', status)
}
}
const { data, error } = await query.order('due_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateSupplierInvoiceInput = await request.json()
// Validate supplier exists and belongs to user
const { data: supplier, error: supplierError } = await supabase
.from('suppliers')
.select('*')
.eq('id', body.supplier_id)
.eq('user_id', user.id)
.single()
if (supplierError || !supplier) {
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
}
// Get next arrival number
const { data: arrivalNum, error: arrivalError } = await supabase
.rpc('get_next_arrival_number', { p_user_id: user.id })
if (arrivalError) {
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
}
// Calculate totals from items
const items = body.items.map((item, index) => {
const vatRate = item.vat_rate ?? 0.25
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
return {
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit || 'st',
unit_price: item.unit_price,
line_total: lineTotal,
account_number: item.account_number,
vat_code: item.vat_code || null,
vat_rate: vatRate,
vat_amount: vatAmount,
}
})
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
const total = Math.round((subtotal + vatAmount) * 100) / 100
const exchangeRate = body.exchange_rate || null
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
// Insert supplier invoice
const { data: invoice, error: invoiceError } = await supabase
.from('supplier_invoices')
.insert({
user_id: user.id,
supplier_id: body.supplier_id,
arrival_number: arrivalNum,
supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date,
due_date: body.due_date,
delivery_date: body.delivery_date || null,
status: 'registered',
currency: body.currency || 'SEK',
exchange_rate: exchangeRate,
vat_treatment: body.vat_treatment || 'standard_25',
reverse_charge: body.reverse_charge || false,
payment_reference: body.payment_reference || null,
subtotal: Math.round(subtotal * 100) / 100,
subtotal_sek: subtotalSek,
vat_amount: Math.round(vatAmount * 100) / 100,
vat_amount_sek: vatAmountSek,
total: Math.round(total * 100) / 100,
total_sek: totalSek,
remaining_amount: Math.round(total * 100) / 100,
notes: body.notes || null,
})
.select()
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
}
// Insert line items
const itemInserts = items.map((item) => ({
supplier_invoice_id: invoice.id,
...item,
}))
const { error: itemsError } = await supabase
.from('supplier_invoice_items')
.insert(itemInserts)
if (itemsError) {
// Clean up invoice on items failure
await supabase.from('supplier_invoices').delete().eq('id', invoice.id)
return NextResponse.json({ error: itemsError.message }, { status: 500 })
}
// Accrual method: create registration journal entry
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
let registrationJournalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
user.id,
invoice as SupplierInvoice,
items as SupplierInvoiceItem[],
supplier.supplier_type
)
if (journalEntry) {
registrationJournalEntryId = journalEntry.id
await supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
}
} catch (err) {
console.error('Failed to create registration journal entry:', err)
}
}
return NextResponse.json({
data: {
...invoice,
items: itemInserts,
registration_journal_entry_id: registrationJournalEntryId,
},
})
}
+145
View File
@@ -0,0 +1,145 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateSupplierInput } from '@/types'
export async function GET(
_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 })
}
// Fetch supplier
const { data: supplier, error } = await supabase
.from('suppliers')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error || !supplier) {
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
}
// Fetch stats: total outstanding & total paid
const { data: invoices } = await supabase
.from('supplier_invoices')
.select('status, total, remaining_amount, paid_amount')
.eq('supplier_id', id)
.eq('user_id', user.id)
const stats = {
total_outstanding: 0,
total_paid: 0,
invoice_count: 0,
}
if (invoices) {
stats.invoice_count = invoices.length
for (const inv of invoices) {
if (inv.status !== 'paid' && inv.status !== 'credited') {
stats.total_outstanding += inv.remaining_amount || 0
}
stats.total_paid += inv.paid_amount || 0
}
}
return NextResponse.json({ data: { ...supplier, stats } })
}
export async function PUT(
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: Partial<CreateSupplierInput> = await request.json()
const { data, error } = await supabase
.from('suppliers')
.update({
name: body.name,
supplier_type: body.supplier_type,
email: body.email,
phone: body.phone,
address_line1: body.address_line1,
address_line2: body.address_line2,
postal_code: body.postal_code,
city: body.city,
country: body.country,
org_number: body.org_number,
vat_number: body.vat_number,
bankgiro: body.bankgiro,
plusgiro: body.plusgiro,
bank_account: body.bank_account,
iban: body.iban,
bic: body.bic,
default_expense_account: body.default_expense_account,
default_payment_terms: body.default_payment_terms,
default_currency: body.default_currency,
notes: body.notes,
})
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
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 })
}
// Check for linked invoices
const { count } = await supabase
.from('supplier_invoices')
.select('id', { count: 'exact', head: true })
.eq('supplier_id', id)
.eq('user_id', user.id)
if (count && count > 0) {
return NextResponse.json(
{ error: 'Kan inte ta bort leverantör med kopplade fakturor' },
{ status: 400 }
)
}
const { error } = await supabase
.from('suppliers')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+71
View File
@@ -0,0 +1,71 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateSupplierInput, Supplier } from '@/types'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', user.id)
.order('name', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateSupplierInput = await request.json()
const { data, error } = await supabase
.from('suppliers')
.insert({
user_id: user.id,
name: body.name,
supplier_type: body.supplier_type,
email: body.email,
phone: body.phone,
address_line1: body.address_line1,
address_line2: body.address_line2,
postal_code: body.postal_code,
city: body.city,
country: body.country || 'SE',
org_number: body.org_number,
vat_number: body.vat_number,
bankgiro: body.bankgiro,
plusgiro: body.plusgiro,
bank_account: body.bank_account,
iban: body.iban,
bic: body.bic,
default_expense_account: body.default_expense_account,
default_payment_terms: body.default_payment_terms || 30,
default_currency: body.default_currency || 'SEK',
notes: body.notes,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
+36 -12
View File
@@ -5,13 +5,14 @@ import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import type { Transaction, TransactionCategory, EntityType } from '@/types'
import type { Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
ensureInitialized()
interface CategorizeRequest {
is_business: boolean
category?: TransactionCategory
vat_treatment?: VatTreatment
}
/**
@@ -20,7 +21,8 @@ interface CategorizeRequest {
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
date: string,
fiscalYearStartMonth: number = 1
): Promise<boolean> {
// Check if a fiscal period already covers this date
const { data: existing } = await supabase
@@ -36,18 +38,38 @@ async function ensureFiscalPeriod(
return true
}
// No fiscal period exists - create one for the year of the transaction
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
// Compute fiscal year period based on start month
const txDate = new Date(date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
const periodStart = `${year}-01-01`
const periodEnd = `${year}-12-31`
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
// Period ends the day before the next fiscal year starts
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
const { error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
}, {
@@ -122,14 +144,15 @@ export async function POST(
})
}
// Fetch company settings to get entity type
// Fetch company settings to get entity type and fiscal year start
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', user.id)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1
// Determine the category to use
const finalCategory: TransactionCategory = is_business
@@ -141,11 +164,12 @@ export async function POST(
finalCategory,
transaction as Transaction,
is_business,
entityType
entityType,
body.vat_treatment
)
// Ensure fiscal period exists for the transaction date
await ensureFiscalPeriod(supabase, user.id, transaction.date)
await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth)
// Try to create journal entry
let journalEntryCreated = false
@@ -1,7 +1,11 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import type { Transaction, Invoice, CreateJournalEntryInput } from '@/types'
import {
getRevenueAccount,
getOutputVatAccount,
} from '@/lib/bookkeeping/invoice-entries'
import type { Transaction, Invoice, CreateJournalEntryInput, VatTreatment } from '@/types'
interface MatchInvoiceRequest {
invoice_id: string
@@ -132,7 +136,16 @@ export async function POST(
const now = new Date().toISOString()
const paidAmount = transaction.amount
// Create journal entry for payment receipt
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
// Create journal entry for payment receipt (method-aware)
let journalEntryId: string | null = null
let journalEntryError: string | null = null
@@ -140,26 +153,69 @@ export async function POST(
const fiscalPeriodId = await ensureFiscalPeriod(supabase, user.id, transaction.date)
if (fiscalPeriodId) {
const journalInput: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: `Betalning mottagen: Faktura ${invoice.invoice_number}`,
source_type: 'invoice_paid',
source_id: invoice.id,
lines: [
let journalInput: CreateJournalEntryInput
if (accountingMethod === 'cash') {
// Kontantmetoden: combined revenue entry at payment
// Debit 1930 Företagskonto, Credit 30xx Försäljning, Credit 26xx Utgående moms
const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment)
const lines: CreateJournalEntryInput['lines'] = [
{
account_number: '1930', // Företagskonto (Bank)
debit_amount: paidAmount,
account_number: '1930',
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
},
{
account_number: '1510', // Kundfordringar (Accounts Receivable)
account_number: revenueAccount,
debit_amount: 0,
credit_amount: paidAmount,
line_description: `Faktura ${invoice.invoice_number} betald`,
credit_amount: invoice.subtotal,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
},
],
]
if (invoice.vat_amount > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment as VatTreatment)
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: invoice.vat_amount,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
})
}
journalInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: `Betalning faktura ${invoice.invoice_number} (kontantmetoden)`,
source_type: 'invoice_cash_payment',
source_id: invoice.id,
lines,
}
} else {
// Faktureringsmetoden: clear receivable
// Debit 1930 Företagskonto, Credit 1510 Kundfordringar
journalInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: `Betalning mottagen: Faktura ${invoice.invoice_number}`,
source_type: 'invoice_paid',
source_id: invoice.id,
lines: [
{
account_number: '1930',
debit_amount: paidAmount,
credit_amount: 0,
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
},
{
account_number: '1510',
debit_amount: 0,
credit_amount: paidAmount,
line_description: `Faktura ${invoice.invoice_number} betald`,
},
],
}
}
const journalEntry = await createJournalEntry(user.id, journalInput)
@@ -0,0 +1,211 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
interface MatchRequest {
supplier_invoice_id: string
}
/**
* Ensure a fiscal period exists for the given date
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
): Promise<string | null> {
const existingPeriodId = await findFiscalPeriod(userId, date)
if (existingPeriodId) return existingPeriodId
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
const { data, error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
period_start: `${year}-01-01`,
period_end: `${year}-12-31`,
}, {
onConflict: 'user_id,period_start,period_end',
})
.select('id')
.single()
if (error) {
console.error('Failed to create fiscal period:', error)
return null
}
return data?.id || null
}
/**
* POST /api/transactions/[id]/match-supplier-invoice
*
* Match a negative transaction (expense) to a supplier invoice.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: transactionId } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: MatchRequest = await request.json()
const { supplier_invoice_id } = body
if (!supplier_invoice_id) {
return NextResponse.json({ error: 'supplier_invoice_id is required' }, { status: 400 })
}
// Fetch the transaction
const { data: transaction, error: fetchTxError } = await supabase
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', user.id)
.single()
if (fetchTxError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Verify transaction is an expense (amount < 0)
if (transaction.amount >= 0) {
return NextResponse.json(
{ error: 'Bara utgiftstransaktioner kan matchas mot leverantörsfakturor' },
{ status: 400 }
)
}
if (transaction.supplier_invoice_id) {
return NextResponse.json(
{ error: 'Transaktionen är redan kopplad till en leverantörsfaktura' },
{ status: 400 }
)
}
// Fetch the invoice with supplier and items
const { data: invoice, error: fetchInvError } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
.eq('id', supplier_invoice_id)
.eq('user_id', user.id)
.single()
if (fetchInvError || !invoice) {
return NextResponse.json({ error: 'Supplier invoice not found' }, { status: 404 })
}
if (invoice.status === 'paid' || invoice.status === 'credited') {
return NextResponse.json(
{ error: 'Leverantörsfakturan är redan betald' },
{ status: 400 }
)
}
const paymentAmount = Math.abs(transaction.amount)
const now = new Date().toISOString()
// Get accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
// Create journal entry
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
const journalEntry = await createSupplierInvoiceCashEntry(
user.id,
invoice as SupplierInvoice,
(invoice.items || []) as SupplierInvoiceItem[],
transaction.date,
invoice.supplier?.supplier_type || 'swedish_business'
)
if (journalEntry) journalEntryId = journalEntry.id
} else {
const journalEntry = await createSupplierInvoicePaymentEntry(
user.id,
invoice as SupplierInvoice,
paymentAmount,
transaction.date
)
if (journalEntry) journalEntryId = journalEntry.id
}
} catch (err) {
console.error('Failed to create payment journal entry:', err)
}
// Update supplier invoice
const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100)
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
const { error: updateInvError } = await supabase
.from('supplier_invoices')
.update({
status: newStatus,
remaining_amount: newRemaining,
paid_amount: newPaidAmount,
paid_at: isFullyPaid ? now : null,
payment_journal_entry_id: journalEntryId,
transaction_id: transactionId,
})
.eq('id', supplier_invoice_id)
if (updateInvError) {
return NextResponse.json({ error: 'Failed to update supplier invoice' }, { status: 500 })
}
// Record payment
await supabase.from('supplier_invoice_payments').insert({
supplier_invoice_id,
payment_date: transaction.date,
amount: paymentAmount,
currency: invoice.currency,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
})
// Update transaction
const { error: updateTxError } = await supabase
.from('transactions')
.update({
supplier_invoice_id,
journal_entry_id: journalEntryId,
is_business: true,
})
.eq('id', transactionId)
if (updateTxError) {
return NextResponse.json({ error: 'Failed to link transaction' }, { status: 500 })
}
return NextResponse.json({
success: true,
invoice_status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
journal_entry_id: journalEntryId,
})
}