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:
co-authored by
Claude Opus 4.6
parent
e8743e6e03
commit
838dc6b8b5
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user