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,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}%`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user