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
+60 -30
View File
@@ -1,4 +1,5 @@
import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType } from '@/types'
import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType, VatTreatment } from '@/types'
import { getVatRate } from './vat-entries'
/**
* Maps TransactionCategory to BAS accounts for journal entry creation
@@ -45,7 +46,8 @@ export function getCategoryAccountMapping(
category: TransactionCategory,
amount: number,
isBusiness: boolean,
entityType: EntityType = 'enskild_firma'
entityType: EntityType = 'enskild_firma',
vatTreatment?: VatTreatment
): CategoryAccountMapping {
// Private/owner transactions use entity-specific accounts
if (!isBusiness) {
@@ -77,7 +79,7 @@ export function getCategoryAccountMapping(
// Business income categories
const incomeMapping: Record<string, string> = {
income_services: '3001', // Försäljning tjänster 25%
income_products: '3001', // Försäljning varor 25%
income_products: '3002', // Försäljning varor 25%
income_other: '3900', // Övriga rörelseintäkter
}
@@ -89,11 +91,14 @@ export function getCategoryAccountMapping(
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
const isVatExempt = vatExemptCategories.includes(category)
// Use provided vatTreatment, or default based on category
const resolvedVat = vatTreatment ?? (isVatExempt ? null : 'standard_25')
return {
debitAccount: expenseAccount,
creditAccount: BANK_ACCOUNT,
vatTreatment: isVatExempt ? null : 'standard_25',
vatDebitAccount: isVatExempt ? null : '2641', // Debiterad ingående moms
vatTreatment: resolvedVat,
vatDebitAccount: resolvedVat ? '2641' : null, // Debiterad ingående moms
vatCreditAccount: null,
}
}
@@ -101,12 +106,33 @@ export function getCategoryAccountMapping(
// Check if it's an income category
if (category.startsWith('income_')) {
const incomeAccount = incomeMapping[category] || '3900'
// Use provided vatTreatment, or default to standard_25
const resolvedVat = vatTreatment ?? 'standard_25'
// Determine output VAT account based on rate
let outputVatAccount: string | null = null
switch (resolvedVat) {
case 'standard_25':
outputVatAccount = '2611' // Utgående moms försäljning 25%
break
case 'reduced_12':
outputVatAccount = '2621' // Utgående moms försäljning 12%
break
case 'reduced_6':
outputVatAccount = '2631' // Utgående moms försäljning 6%
break
default:
outputVatAccount = null
break
}
return {
debitAccount: BANK_ACCOUNT,
creditAccount: incomeAccount,
vatTreatment: 'standard_25',
vatTreatment: resolvedVat,
vatDebitAccount: null,
vatCreditAccount: '2610', // Utgående moms
vatCreditAccount: outputVatAccount,
}
}
@@ -138,34 +164,38 @@ export function buildMappingResultFromCategory(
category: TransactionCategory,
transaction: Transaction,
isBusiness: boolean,
entityType: EntityType = 'enskild_firma'
entityType: EntityType = 'enskild_firma',
vatTreatment?: VatTreatment
): MappingResult {
const mapping = getCategoryAccountMapping(category, transaction.amount, isBusiness, entityType)
const mapping = getCategoryAccountMapping(category, transaction.amount, isBusiness, entityType, vatTreatment)
const vatLines: VatJournalLine[] = []
// Calculate VAT if applicable (Swedish standard: gross amount includes 25% VAT)
// VAT = gross * 0.25 / 1.25 = gross * 0.20
if (isBusiness && mapping.vatTreatment === 'standard_25') {
const grossAmount = Math.abs(transaction.amount)
const vatAmount = Math.round((grossAmount * 0.20) * 100) / 100 // Round to öre
// Calculate VAT if applicable using the resolved treatment from mapping
const treatment = mapping.vatTreatment as VatTreatment | null
if (isBusiness && treatment) {
const vatRate = getVatRate(treatment)
if (vatRate > 0) {
const grossAmount = Math.abs(transaction.amount)
const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100
if (transaction.amount < 0 && mapping.vatDebitAccount) {
// Expense: Ingående moms (deductible VAT)
vatLines.push({
account_number: mapping.vatDebitAccount,
debit_amount: vatAmount,
credit_amount: 0,
description: 'Ingående moms 25%',
})
} else if (transaction.amount > 0 && mapping.vatCreditAccount) {
// Income: Utgående moms (output VAT)
vatLines.push({
account_number: mapping.vatCreditAccount,
debit_amount: 0,
credit_amount: vatAmount,
description: 'Utgående moms 25%',
})
if (transaction.amount < 0 && mapping.vatDebitAccount) {
// Expense: Ingående moms (deductible VAT)
vatLines.push({
account_number: mapping.vatDebitAccount,
debit_amount: vatAmount,
credit_amount: 0,
description: `Ingående moms ${vatRate * 100}%`,
})
} else if (transaction.amount > 0 && mapping.vatCreditAccount) {
// Income: Utgående moms (output VAT)
vatLines.push({
account_number: mapping.vatCreditAccount,
debit_amount: 0,
credit_amount: vatAmount,
description: `Utgående moms ${vatRate * 100}%`,
})
}
}
}
+2 -3
View File
@@ -125,9 +125,8 @@ function buildLineInserts(
amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null,
exchange_rate: line.exchange_rate || null,
line_description: line.line_description || null,
tax_code: line.tax_code || null,
cost_center: line.cost_center || null,
project: line.project || null,
cost_center_id: line.cost_center || null,
project_id: line.project || null,
sort_order: index,
}))
}
+63 -2
View File
@@ -187,10 +187,71 @@ export async function createCreditNoteJournalEntry(
return createJournalEntry(userId, input)
}
/**
* Create journal entry for kontantmetoden (cash method) when payment is received.
* Combined entry: revenue + VAT recognised at payment.
*
* Debit 1930 Företagskonto [total]
* Credit 30xx Försäljning [subtotal]
* Credit 26xx Utgående moms [vat_amount] (if applicable)
*/
export async function createInvoiceCashEntry(
userId: string,
invoice: Invoice,
paymentDate: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for payment date:', paymentDate)
return null
}
const revenueAccount = getRevenueAccount(invoice.vat_treatment)
const lines: CreateJournalEntryLineInput[] = []
// Debit: Företagskonto (total received)
lines.push({
account_number: '1930',
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Betalning faktura ${invoice.invoice_number}`,
})
// Credit: Revenue account (subtotal excl VAT)
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.subtotal,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
})
// Credit: Output VAT (if applicable)
if (invoice.vat_amount > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: invoice.vat_amount,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
})
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: `Betalning faktura ${invoice.invoice_number} (kontantmetoden)`,
source_type: 'invoice_cash_payment',
source_id: invoice.id,
lines,
}
return createJournalEntry(userId, input)
}
/**
* Get the appropriate revenue account based on VAT treatment
*/
function getRevenueAccount(vatTreatment: VatTreatment): string {
export function getRevenueAccount(vatTreatment: VatTreatment): string {
switch (vatTreatment) {
case 'standard_25':
return '3001' // Försäljning 25%
@@ -212,7 +273,7 @@ function getRevenueAccount(vatTreatment: VatTreatment): string {
/**
* Get the output VAT account based on VAT treatment
*/
function getOutputVatAccount(vatTreatment: VatTreatment): string {
export function getOutputVatAccount(vatTreatment: VatTreatment): string {
switch (vatTreatment) {
case 'standard_25':
return '2611'
+363
View File
@@ -0,0 +1,363 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { generateReverseChargeLines } from './vat-entries'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
SupplierInvoice,
SupplierInvoiceItem,
} from '@/types'
/**
* Create journal entry when a supplier invoice is registered (accrual method)
*
* Swedish domestic (25% VAT):
* Debit 5xxx/6xxx (per item's account_number) [item line_total]
* Debit 2641 Ingående moms [total VAT]
* Credit 2440 Leverantörsskulder [total incl VAT]
*
* EU reverse charge (supplier_type = 'eu_business'):
* Debit 5xxx/6xxx (per item) [total]
* Debit 2645 Beräknad ingående moms [fiktiv VAT]
* Credit 2614 Utgående moms omvänd [fiktiv VAT]
* Credit 2440 Leverantörsskulder [total]
*/
export async function createSupplierInvoiceRegistrationEntry(
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, invoice.invoice_date)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
}
const lines: CreateJournalEntryLineInput[] = []
const desc = `Lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
// Aggregate expense amounts by account number
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
expenseByAccount.set(item.account_number, current + item.line_total)
}
// Debit: Expense accounts
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
}
if (supplierType === 'eu_business' && invoice.reverse_charge) {
// EU reverse charge: fiktiv moms entries
const vatRate = getDefaultVatRate(invoice.vat_treatment)
const reverseChargeLines = generateReverseChargeLines(invoice.subtotal, vatRate)
lines.push(...reverseChargeLines)
} else if (invoice.vat_amount > 0) {
// Domestic: Debit ingående moms
lines.push({
account_number: '2641',
debit_amount: Math.round(invoice.vat_amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${desc}`,
})
}
// Credit: Leverantörsskulder
lines.push({
account_number: '2440',
debit_amount: 0,
credit_amount: Math.round(invoice.total * 100) / 100,
line_description: desc,
currency: invoice.currency !== 'SEK' ? invoice.currency : undefined,
amount_in_currency: invoice.currency !== 'SEK' ? invoice.total : undefined,
exchange_rate: invoice.exchange_rate || undefined,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: desc,
source_type: 'supplier_invoice_registered',
source_id: invoice.id,
lines,
}
return createJournalEntry(userId, input)
}
/**
* Create journal entry when a supplier invoice is paid (accrual method)
*
* Debit 2440 Leverantörsskulder [payment amount]
* Credit 1930 Företagskonto [payment amount]
*
* With exchange rate difference:
* Debit 2440 Leverantörsskulder [original SEK amount]
* Credit 1930 Företagskonto [actual SEK paid]
* Credit/Debit 3960/7960 [difference]
*/
export async function createSupplierInvoicePaymentEntry(
userId: string,
invoice: SupplierInvoice,
paymentAmount: number,
paymentDate: string,
exchangeRateDifference?: number
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for payment date:', paymentDate)
return null
}
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
const lines: CreateJournalEntryLineInput[] = []
if (exchangeRateDifference && exchangeRateDifference !== 0) {
// Foreign currency with exchange rate difference
const originalSekAmount = paymentAmount
const actualSekPaid = paymentAmount - exchangeRateDifference
// Debit: Clear leverantörsskulder at original booked SEK amount
lines.push({
account_number: '2440',
debit_amount: Math.round(originalSekAmount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
// Credit: Bank at actual SEK paid
lines.push({
account_number: '1930',
debit_amount: 0,
credit_amount: Math.round(actualSekPaid * 100) / 100,
line_description: desc,
})
// Exchange rate difference
if (exchangeRateDifference > 0) {
// Gain: Credit 3960
lines.push({
account_number: '3960',
debit_amount: 0,
credit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
line_description: 'Valutakursvinst',
})
} else {
// Loss: Debit 7960
lines.push({
account_number: '7960',
debit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
credit_amount: 0,
line_description: 'Valutakursförlust',
})
}
} else {
// Standard SEK payment
lines.push({
account_number: '2440',
debit_amount: Math.round(paymentAmount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
lines.push({
account_number: '1930',
debit_amount: 0,
credit_amount: Math.round(paymentAmount * 100) / 100,
line_description: desc,
})
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: 'supplier_invoice_paid',
source_id: invoice.id,
lines,
}
return createJournalEntry(userId, input)
}
/**
* Create journal entry for cash method (kontantmetoden)
* Combined entry at payment time:
*
* Debit 5xxx/6xxx (per item) [line_total]
* Debit 2641 Ingående moms [total VAT]
* Credit 1930 Företagskonto [total incl VAT]
*/
export async function createSupplierInvoiceCashEntry(
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
paymentDate: string,
supplierType: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for payment date:', paymentDate)
return null
}
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (kontantmetoden)`
const lines: CreateJournalEntryLineInput[] = []
// Aggregate expense amounts by account number
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
expenseByAccount.set(item.account_number, current + item.line_total)
}
// Debit: Expense accounts
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
}
if (supplierType === 'eu_business' && invoice.reverse_charge) {
// EU reverse charge: fiktiv moms entries
const vatRate = getDefaultVatRate(invoice.vat_treatment)
const reverseChargeLines = generateReverseChargeLines(invoice.subtotal, vatRate)
lines.push(...reverseChargeLines)
} else if (invoice.vat_amount > 0) {
// Domestic: Debit ingående moms
lines.push({
account_number: '2641',
debit_amount: Math.round(invoice.vat_amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${desc}`,
})
}
// Credit: Företagskonto
lines.push({
account_number: '1930',
debit_amount: 0,
credit_amount: Math.round(invoice.total * 100) / 100,
line_description: desc,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: 'supplier_invoice_cash_payment',
source_id: invoice.id,
lines,
}
return createJournalEntry(userId, input)
}
/**
* Create journal entry for a supplier credit note (reversal of registration)
*
* Debit 2440 Leverantörsskulder [total]
* Credit 5xxx/6xxx (per item) [line_total]
* Credit 2641 Ingående moms [total VAT]
*/
export async function createSupplierCreditNoteEntry(
userId: string,
creditNote: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, creditNote.invoice_date)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
return null
}
const desc = `Kreditfaktura lev. ${creditNote.supplier_invoice_number} (ankomst ${creditNote.arrival_number})`
const lines: CreateJournalEntryLineInput[] = []
const absTotal = Math.abs(creditNote.total)
const absVat = Math.abs(creditNote.vat_amount)
// Debit: Leverantörsskulder
lines.push({
account_number: '2440',
debit_amount: Math.round(absTotal * 100) / 100,
credit_amount: 0,
line_description: desc,
})
// Credit: Expense accounts (reverse)
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
expenseByAccount.set(item.account_number, current + Math.abs(item.line_total))
}
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
debit_amount: 0,
credit_amount: Math.round(amount * 100) / 100,
line_description: desc,
})
}
if (supplierType === 'eu_business' && creditNote.reverse_charge) {
// Reverse the fiktiv moms (swap debit/credit from registration)
const vatRate = getDefaultVatRate(creditNote.vat_treatment)
const vatAmount = Math.round(Math.abs(creditNote.subtotal) * vatRate * 100) / 100
lines.push({
account_number: '2645',
debit_amount: 0,
credit_amount: vatAmount,
line_description: `Omvänd fiktiv ingående moms ${desc}`,
})
lines.push({
account_number: '2614',
debit_amount: vatAmount,
credit_amount: 0,
line_description: `Omvänd fiktiv utgående moms ${desc}`,
})
} else if (absVat > 0) {
// Credit: Ingående moms (reverse)
lines.push({
account_number: '2641',
debit_amount: 0,
credit_amount: Math.round(absVat * 100) / 100,
line_description: `Ingående moms ${desc}`,
})
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: creditNote.invoice_date,
description: desc,
source_type: 'supplier_credit_note',
source_id: creditNote.id,
lines,
}
return createJournalEntry(userId, input)
}
/**
* Get default VAT rate from treatment string
*/
function getDefaultVatRate(vatTreatment: string): number {
switch (vatTreatment) {
case 'standard_25': return 0.25
case 'reduced_12': return 0.12
case 'reduced_6': return 0.06
default: return 0.25
}
}
+42 -9
View File
@@ -50,16 +50,16 @@ export async function createTransactionJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
if (mappingResult.default_private) {
// Private expense
// Private expense — use entity-specific account from mappingResult
lines.push(
{
account_number: '2013', // Övriga egna uttag
account_number: mappingResult.debit_account,
debit_amount: absAmount,
credit_amount: 0,
line_description: `Privat: ${transaction.description}`,
},
{
account_number: '1930', // Företagskonto
account_number: mappingResult.credit_account || '1930',
debit_amount: 0,
credit_amount: absAmount,
line_description: transaction.description,
@@ -116,20 +116,53 @@ export async function createTransactionJournalEntry(
const debitAccount = mappingResult.debit_account || '1930'
const creditAccount = mappingResult.credit_account
lines.push(
{
if (mappingResult.vat_lines.length > 0) {
// Has output VAT
const vatCredit = mappingResult.vat_lines
.filter(l => l.credit_amount > 0)
.reduce((sum, l) => sum + l.credit_amount, 0)
const netAmount = Math.round((absAmount - vatCredit) * 100) / 100
// Debit bank for gross amount
lines.push({
account_number: debitAccount,
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
},
{
})
// Credit revenue for net amount
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: absAmount,
credit_amount: netAmount,
line_description: transaction.description,
})
// Credit output VAT
for (const vatLine of mappingResult.vat_lines) {
lines.push({
account_number: vatLine.account_number,
debit_amount: vatLine.debit_amount,
credit_amount: vatLine.credit_amount,
line_description: vatLine.description,
})
}
)
} else {
// No VAT - simple two-line entry
lines.push(
{
account_number: debitAccount,
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
},
{
account_number: creditAccount,
debit_amount: 0,
credit_amount: absAmount,
line_description: transaction.description,
}
)
}
}
const input: CreateJournalEntryInput = {
+30 -49
View File
@@ -13,7 +13,7 @@ import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadS
/**
* Compute SHA-256 hash of a file buffer
*/
async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
export async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
@@ -91,6 +91,11 @@ export async function uploadDocument(
/**
* Create a new version of an existing document (WORM: old version is superseded)
*
* Uses the create_document_version RPC for atomic versioning with:
* - Row-level locking (prevents concurrent versioning race condition)
* - Cryptographic hash chain (prev_version_hash links to previous version)
* - Single transaction (insert new + mark old superseded)
*/
export async function createNewVersion(
userId: string,
@@ -99,28 +104,12 @@ export async function createNewVersion(
): Promise<DocumentAttachment> {
const supabase = await createClient()
// Fetch the original/current version
const { data: current, error: fetchError } = await supabase
.from('document_attachments')
.select('*')
.eq('id', originalId)
.eq('user_id', userId)
.eq('is_current_version', true)
.single()
if (fetchError || !current) {
throw new Error('Original document not found or not the current version')
}
const rootOriginalId = current.original_id || current.id
const newVersion = current.version + 1
// Compute SHA-256 hash
const sha256Hash = await computeSHA256(file.buffer)
// Upload new file
// Upload new file to Storage
const timestamp = Date.now()
const storagePath = `documents/${userId}/${timestamp}_v${newVersion}_${file.name}`
const storagePath = `documents/${userId}/${timestamp}_${file.name}`
const { error: uploadError } = await supabase.storage
.from('documents')
@@ -133,41 +122,33 @@ export async function createNewVersion(
throw new Error(`Failed to upload new version: ${uploadError.message}`)
}
// Create new version record
const { data: newDoc, error: insertError } = await supabase
.from('document_attachments')
.insert({
user_id: userId,
storage_path: storagePath,
file_name: file.name,
file_size_bytes: file.buffer.byteLength,
mime_type: file.type || null,
sha256_hash: sha256Hash,
version: newVersion,
original_id: rootOriginalId,
is_current_version: true,
uploaded_by: userId,
upload_source: current.upload_source,
digitization_date: new Date().toISOString(),
journal_entry_id: current.journal_entry_id,
journal_entry_line_id: current.journal_entry_line_id,
})
.select()
.single()
// Atomic version creation via RPC (row lock + hash chain + supersede in one tx)
const { data: newDocId, error: rpcError } = await supabase.rpc('create_document_version', {
p_user_id: userId,
p_original_doc_id: originalId,
p_storage_path: storagePath,
p_file_name: file.name,
p_file_size_bytes: file.buffer.byteLength,
p_mime_type: file.type || null,
p_sha256_hash: sha256Hash,
})
if (insertError) {
if (rpcError) {
// Clean up uploaded file on RPC failure
await supabase.storage.from('documents').remove([storagePath])
throw new Error(`Failed to create new version record: ${insertError.message}`)
throw new Error(`Failed to create new version: ${rpcError.message}`)
}
// Mark old version as superseded
await supabase
// Fetch the complete new version record
const { data: newDoc, error: fetchError } = await supabase
.from('document_attachments')
.update({
is_current_version: false,
superseded_by_id: newDoc.id,
})
.eq('id', current.id)
.select('*')
.eq('id', newDocId)
.single()
if (fetchError || !newDoc) {
throw new Error('Failed to fetch new version record')
}
return newDoc as DocumentAttachment
}
+103
View File
@@ -0,0 +1,103 @@
import { createClient } from '@/lib/supabase/server'
export interface SupplierLedgerEntry {
supplier_id: string
supplier_name: string
current: number
days_1_30: number
days_31_60: number
days_61_90: number
days_90_plus: number
total_outstanding: number
}
export interface SupplierLedgerReport {
entries: SupplierLedgerEntry[]
total_outstanding: number
total_current: number
total_overdue: number
unpaid_count: number
}
/**
* Generate supplier ledger (leverantörsreskontra) with aging analysis
*/
export async function generateSupplierLedger(
userId: string,
asOfDate?: string
): Promise<SupplierLedgerReport> {
const supabase = await createClient()
const refDate = asOfDate ? new Date(asOfDate) : new Date()
// Fetch all unpaid/partially_paid supplier invoices
const { data: invoices, error } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(id, name)')
.eq('user_id', userId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
if (error || !invoices) {
return {
entries: [],
total_outstanding: 0,
total_current: 0,
total_overdue: 0,
unpaid_count: 0,
}
}
// Group by supplier and calculate aging
const bySupplier = new Map<string, SupplierLedgerEntry>()
for (const inv of invoices) {
const supplierId = inv.supplier_id
const supplierName = inv.supplier?.name || 'Okänd leverantör'
if (!bySupplier.has(supplierId)) {
bySupplier.set(supplierId, {
supplier_id: supplierId,
supplier_name: supplierName,
current: 0,
days_1_30: 0,
days_31_60: 0,
days_61_90: 0,
days_90_plus: 0,
total_outstanding: 0,
})
}
const entry = bySupplier.get(supplierId)!
const dueDate = new Date(inv.due_date)
const daysOverdue = Math.floor((refDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
const amount = inv.remaining_amount || 0
if (daysOverdue <= 0) {
entry.current += amount
} else if (daysOverdue <= 30) {
entry.days_1_30 += amount
} else if (daysOverdue <= 60) {
entry.days_31_60 += amount
} else if (daysOverdue <= 90) {
entry.days_61_90 += amount
} else {
entry.days_90_plus += amount
}
entry.total_outstanding += amount
}
const entries = Array.from(bySupplier.values())
.sort((a, b) => b.total_outstanding - a.total_outstanding)
const total_outstanding = entries.reduce((sum, e) => sum + e.total_outstanding, 0)
const total_current = entries.reduce((sum, e) => sum + e.current, 0)
const total_overdue = total_outstanding - total_current
return {
entries,
total_outstanding: Math.round(total_outstanding * 100) / 100,
total_current: Math.round(total_current * 100) / 100,
total_overdue: Math.round(total_overdue * 100) / 100,
unpaid_count: invoices.length,
}
}
+53
View File
@@ -0,0 +1,53 @@
import { createClient } from '@/lib/supabase/server'
export interface ReconciliationResult {
supplier_ledger_total: number
account_2440_balance: number
difference: number
is_reconciled: boolean
}
/**
* Compare sum of open supplier invoices against account 2440 balance
*/
export async function generateReconciliation(
userId: string,
periodId: string
): Promise<ReconciliationResult> {
const supabase = await createClient()
// Get total outstanding from supplier invoices
const { data: invoices } = await supabase
.from('supplier_invoices')
.select('remaining_amount')
.eq('user_id', userId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
const supplierLedgerTotal = (invoices || [])
.reduce((sum, inv) => sum + (inv.remaining_amount || 0), 0)
// Get account 2440 balance from journal entry lines
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entry_id')
.eq('account_number', '2440')
// Filter to posted entries in the period
let account2440Balance = 0
if (journalLines) {
// Account 2440 is a liability: credit normal balance
// Balance = credits - debits
for (const line of journalLines) {
account2440Balance += (line.credit_amount || 0) - (line.debit_amount || 0)
}
}
const difference = Math.round((supplierLedgerTotal - account2440Balance) * 100) / 100
return {
supplier_ledger_total: Math.round(supplierLedgerTotal * 100) / 100,
account_2440_balance: Math.round(account2440Balance * 100) / 100,
difference,
is_reconciled: Math.abs(difference) < 0.01,
}
}