feat: per-line VAT, invoice document types, ledger-based VAT declaration, bank reconciliation, and pagination

Per-line VAT rates:
- Add generatePerRateLines() to group invoice items by vat_rate with separate
  revenue + VAT lines per rate group (invoice-entries.ts)
- Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts)
- PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices
- Invoice create/review UI supports per-line rate selection
- Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput

Invoice document types (proforma, delivery note):
- Add InvoiceDocumentType, document_type and converted_from_id to Invoice type
- PDF hides prices for delivery notes, adds proforma notice
- Email templates support all document types
- mark-paid skips journal entries for non-invoice document types
- Migration 031: invoice_document_type

Accounting method support:
- Add AccountingMethod type (accrual/cash)
- Migration 032: add_accounting_method column to company_settings

VAT declaration rewrite:
- Rewrite to read directly from general ledger (26xx/3xxx account lines)
  instead of aggregating invoices/transactions/receipts
- ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances

Bank reconciliation:
- Transaction ingest now pre-fetches unlinked GL lines and attempts
  auto-reconciliation during import
- Add transaction.reconciled event type
- Add ReconciliationMethod type and reconciliation_method on Transaction
- Migration 030: bank_reconciliation
- New reconciliation engine, API routes, and BankReconciliationView component

Pagination (fetchAllRows):
- New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit
- Adopted in all report generators, SIE/SRU export, account list APIs

Fiscal period validation:
- New validate-period-duration.ts enforces max 18 months per BFL 3 kap.
- Applied in period-service.ts and fiscal-periods API

Account mapper simplification:
- Remove Levenshtein/fuzzy matching, use exact account number match only

Swedbank parser improvements:
- Support abbreviated headers (Clnr, Bokfdag, Radnr)
- Use Referens column as counterparty

Chart of accounts management:
- Add DELETE endpoint with system account and usage protection
- PUT uses partial updates
- New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager

Tax deadline corrections:
- Rewrite inkomstdeklaration_ab using Skatteverket lookup table
- Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3

Onboarding first fiscal year:
- Add first fiscal year toggle with date pickers and 18-month validation

UI terminology:
- Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout

Report column fix:
- Fix start_date/end_date to period_start/period_end in report queries

Supplier invoice input:
- CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept)

Misc:
- SIE import uses upsert for idempotent account creation
- account-descriptions.ts falls back to BAS reference data
- Add invoice_default_notes to CompanySettings
- Update CLAUDE.md to reflect current project state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-21 14:57:15 +01:00
co-authored by Claude Opus 4.6
parent f1d187c005
commit 91e2c1705a
106 changed files with 9950 additions and 1641 deletions
+66 -6
View File
@@ -1,6 +1,63 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ number: string }> }
) {
const { number } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch the account to check if it's a system account
const { data: account, error: fetchError } = await supabase
.from('chart_of_accounts')
.select('id, is_system_account')
.eq('user_id', user.id)
.eq('account_number', number)
.single()
if (fetchError || !account) {
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
}
if (account.is_system_account) {
return NextResponse.json(
{ error: 'Systemkonton kan inte tas bort' },
{ status: 400 }
)
}
// Check if account is referenced in posted journal entries
const { count } = await supabase
.from('journal_entry_lines')
.select('id', { count: 'exact', head: true })
.eq('account_number', number)
if (count && count > 0) {
return NextResponse.json(
{ error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' },
{ status: 400 }
)
}
const { error: deleteError } = await supabase
.from('chart_of_accounts')
.delete()
.eq('id', account.id)
.eq('user_id', user.id)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ number: string }> }
@@ -15,14 +72,17 @@ export async function PUT(
const body = await request.json()
// Build update object with only provided fields
const updates: Record<string, unknown> = {}
if (body.account_name !== undefined) updates.account_name = body.account_name
if (body.is_active !== undefined) updates.is_active = body.is_active
if (body.description !== undefined) updates.description = body.description
if (body.default_vat_code !== undefined) updates.default_vat_code = body.default_vat_code
if (body.sru_code !== undefined) updates.sru_code = body.sru_code
const { data, error } = await supabase
.from('chart_of_accounts')
.update({
account_name: body.account_name,
is_active: body.is_active,
description: body.description,
default_vat_code: body.default_vat_code,
})
.update(updates)
.eq('user_id', user.id)
.eq('account_number', number)
.select()
@@ -0,0 +1,84 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
/**
* POST /api/bookkeeping/accounts/activate
*
* Batch-activate BAS accounts for a user.
* Accepts { account_numbers: string[] } and creates chart_of_accounts rows from reference data.
* Skips any accounts that already exist for the user.
*/
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 = await request.json()
const accountNumbers: string[] = body.account_numbers
if (!Array.isArray(accountNumbers) || accountNumbers.length === 0) {
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
}
// Check which accounts already exist
const { data: existing } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
.in('account_number', accountNumbers)
const existingNumbers = new Set((existing || []).map((a) => a.account_number))
// Build rows for accounts that don't already exist
const newAccounts = accountNumbers
.filter((num) => !existingNumbers.has(num))
.map((num) => {
const ref = getBASReference(num)
if (!ref) return null
return {
user_id: user.id,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
account_group: ref.account_group,
account_type: ref.account_type,
normal_balance: ref.normal_balance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: ref.description,
sru_code: ref.sru_code,
sort_order: parseInt(ref.account_number),
}
})
.filter(Boolean)
if (newAccounts.length === 0) {
return NextResponse.json({
data: [],
message: 'All accounts already activated',
activated: 0,
skipped: accountNumbers.length,
})
}
const { data, error } = await supabase
.from('chart_of_accounts')
.insert(newAccounts)
.select()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({
data,
activated: data?.length || 0,
skipped: accountNumbers.length - (data?.length || 0),
})
}
@@ -0,0 +1,62 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
/**
* GET /api/bookkeeping/accounts/reference
*
* Returns the full BAS reference catalog merged with the user's activation status.
* Each reference account includes: is_activated (exists in user's chart), is_active, is_system_account, is_custom.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
try {
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, is_active, is_system_account')
.eq('user_id', user.id)
.range(from, to)
)
// Build lookup map
const userAccountMap = new Map(
userAccounts.map((a) => [a.account_number, a])
)
// Merge reference with user status
const merged = BAS_REFERENCE.map((ref) => {
const userAccount = userAccountMap.get(ref.account_number)
return {
...ref,
is_activated: !!userAccount,
is_active: userAccount?.is_active ?? false,
is_system_account: userAccount?.is_system_account ?? false,
}
})
// Also identify custom accounts (in user's chart but not in BAS reference)
const basNumbers = new Set(BAS_REFERENCE.map((r) => r.account_number))
const customAccounts = userAccounts
.filter((a) => !basNumbers.has(a.account_number))
.map((a) => ({
account_number: a.account_number,
is_custom: true,
is_activated: true,
is_active: a.is_active,
is_system_account: a.is_system_account,
}))
return NextResponse.json({ data: merged, customAccounts })
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
}
}
+22 -19
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
@@ -13,27 +14,29 @@ export async function GET(request: Request) {
const accountClass = searchParams.get('class')
const activeOnly = searchParams.get('active') !== 'false'
let query = supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.order('sort_order')
try {
const data = await fetchAllRows(({ from, to }) => {
let query = supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.order('sort_order')
if (activeOnly) {
query = query.eq('is_active', true)
if (activeOnly) {
query = query.eq('is_active', true)
}
if (accountClass) {
query = query.eq('account_class', parseInt(accountClass))
}
return query.range(from, to)
})
return NextResponse.json({ data })
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
}
if (accountClass) {
query = query.eq('account_class', parseInt(accountClass))
}
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateFiscalPeriodInput } from '@/types'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
export async function GET() {
const supabase = await createClient()
@@ -33,6 +34,12 @@ export async function POST(request: Request) {
const body = await request.json() as CreateFiscalPeriodInput
// Validate period duration (max 18 months per BFL 3 kap.)
const durationError = validatePeriodDuration(body.period_start, body.period_end)
if (durationError) {
return NextResponse.json({ error: durationError }, { status: 400 })
}
const { data, error } = await supabase
.from('fiscal_periods')
.insert({
+15 -28
View File
@@ -71,27 +71,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'No accounts provided' }, { status: 400 })
}
// Fetch existing accounts to avoid duplicates
const { data: existingAccounts } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
const existingNumbers = new Set(existingAccounts?.map(a => a.account_number) || [])
// Filter to only accounts that don't exist
const newAccounts = accounts.filter(a => !existingNumbers.has(a.number))
if (newAccounts.length === 0) {
return NextResponse.json({
success: true,
created: 0,
message: 'All accounts already exist'
})
}
// Prepare accounts for insertion
const accountsToInsert = newAccounts.map(account => {
// Prepare accounts for upsert (idempotent — safe to retry)
const accountsToUpsert = accounts.map(account => {
const accountClass = parseInt(account.number.charAt(0), 10) || 1
const accountGroup = account.number.substring(0, 2)
const accountType = getAccountType(account.number)
@@ -112,26 +93,32 @@ export async function POST(request: Request) {
}
})
// Insert in batches of 100 to avoid timeout
// Upsert in batches of 100 to avoid timeout
// ignoreDuplicates skips rows that already exist (no update)
const batchSize = 100
let totalCreated = 0
for (let i = 0; i < accountsToInsert.length; i += batchSize) {
const batch = accountsToInsert.slice(i, i + batchSize)
for (let i = 0; i < accountsToUpsert.length; i += batchSize) {
const batch = accountsToUpsert.slice(i, i + batchSize)
const { error } = await supabase
const { data: upserted, error } = await supabase
.from('chart_of_accounts')
.insert(batch)
.upsert(batch, {
onConflict: 'user_id,account_number',
ignoreDuplicates: true,
count: 'exact',
})
.select('account_number')
if (error) {
console.error('Error inserting accounts batch:', error)
console.error('Error upserting accounts batch:', error)
return NextResponse.json({
error: `Failed to create accounts: ${error.message}`,
created: totalCreated,
}, { status: 500 })
}
totalCreated += batch.length
totalCreated += upserted?.length ?? batch.length
}
return NextResponse.json({
+12 -8
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { suggestMappings } from '@/lib/import/account-mapper'
@@ -53,15 +54,18 @@ export async function POST(request: Request) {
if (mappingsJson) {
mappings = JSON.parse(mappingsJson)
} else {
// Fetch user's chart of accounts and generate mappings
const { data: basAccounts } = await supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
const basAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
if (!basAccounts || basAccounts.length === 0) {
if (basAccounts.length === 0) {
return NextResponse.json({
error: 'No chart of accounts found. Please complete onboarding first.',
}, { status: 400 })
+12 -8
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import {
parseSIEFile,
@@ -77,15 +78,18 @@ export async function POST(request: Request) {
}, { status: 400 })
}
// Fetch user's chart of accounts
const { data: basAccounts } = await supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
const basAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
if (!basAccounts || basAccounts.length === 0) {
if (basAccounts.length === 0) {
return NextResponse.json({
error: 'No chart of accounts found. Please complete onboarding first.',
}, { status: 400 })
+137
View File
@@ -0,0 +1,137 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import type { Invoice } from '@/types'
ensureInitialized()
/**
* POST /api/invoices/[id]/convert
*
* Converts a proforma invoice to a real invoice.
* Copies all data, generates a real invoice number, and marks the proforma as cancelled.
*/
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 proforma with items
const { data: proforma, error: proformaError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (proformaError || !proforma) {
return NextResponse.json({ error: 'Proformafakturan hittades inte' }, { status: 404 })
}
if (proforma.document_type !== 'proforma') {
return NextResponse.json(
{ error: 'Endast proformafakturor kan konverteras' },
{ status: 400 }
)
}
if (proforma.status === 'cancelled') {
return NextResponse.json(
{ error: 'Denna proformafaktura har redan makuleras' },
{ status: 400 }
)
}
// Generate real invoice number
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: user.id,
})
// Create the real invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: user.id,
customer_id: proforma.customer_id,
invoice_number: invoiceNumber,
invoice_date: new Date().toISOString().split('T')[0],
due_date: proforma.due_date,
currency: proforma.currency,
exchange_rate: proforma.exchange_rate,
exchange_rate_date: proforma.exchange_rate_date,
subtotal: proforma.subtotal,
subtotal_sek: proforma.subtotal_sek,
vat_amount: proforma.vat_amount,
vat_amount_sek: proforma.vat_amount_sek,
total: proforma.total,
total_sek: proforma.total_sek,
vat_treatment: proforma.vat_treatment,
vat_rate: proforma.vat_rate,
moms_ruta: proforma.moms_ruta,
reverse_charge_text: proforma.reverse_charge_text,
your_reference: proforma.your_reference,
our_reference: proforma.our_reference,
notes: proforma.notes,
document_type: 'invoice',
converted_from_id: id,
})
.select()
.single()
if (invoiceError) {
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
}
// Copy invoice items
const items = (proforma.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
invoice_id: invoice.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,
}))
if (items.length > 0) {
const { error: itemsError } = await supabase
.from('invoice_items')
.insert(items)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json({ error: itemsError.message }, { status: 500 })
}
}
// Mark proforma as cancelled
await supabase
.from('invoices')
.update({ status: 'cancelled' })
.eq('id', id)
// Fetch complete invoice
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
})
}
return NextResponse.json({ data: completeInvoice })
}
+24 -20
View File
@@ -77,29 +77,33 @@ export async function POST(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
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,
entityType
)
journalEntryId = journalEntry?.id ?? null
if (isRealInvoice) {
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,
entityType
)
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
console.error('Failed to create payment journal entry on mark-paid:', err)
}
} catch (err) {
console.error('Failed to create payment journal entry on mark-paid:', err)
}
return NextResponse.json({
+4 -6
View File
@@ -45,10 +45,7 @@ export async function POST(
// Update status to sent
const { error: updateError } = await supabase
.from('invoices')
.update({
status: 'sent',
sent_at: new Date().toISOString(),
})
.update({ status: 'sent' })
.eq('id', id)
.eq('user_id', user.id)
@@ -65,9 +62,10 @@ export async function POST(
const accountingMethod = settings?.accounting_method || 'accrual'
// Faktureringsmetoden: book at send
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
if (accountingMethod === 'accrual') {
if (isRealInvoice && accountingMethod === 'accrual') {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
+17 -11
View File
@@ -113,11 +113,19 @@ export async function POST(
company: company as CompanySettings
}
// Determine filename
// Determine filename based on document type
const isCreditNote = !!invoice.credited_invoice_id
const filename = isCreditNote
? `kreditfaktura-${invoice.invoice_number}.pdf`
: `faktura-${invoice.invoice_number}.pdf`
const docType = invoice.document_type || 'invoice'
let filename: string
if (isCreditNote) {
filename = `kreditfaktura-${invoice.invoice_number}.pdf`
} else if (docType === 'proforma') {
filename = `proformafaktura-${invoice.invoice_number}.pdf`
} else if (docType === 'delivery_note') {
filename = `foljesedel-${invoice.invoice_number}.pdf`
} else {
filename = `faktura-${invoice.invoice_number}.pdf`
}
// Send email
const result = await sendEmail({
@@ -144,13 +152,10 @@ export async function POST(
)
}
// Update invoice status to "sent" and set sent_at timestamp
// Update invoice status to "sent"
const { error: updateError } = await supabase
.from('invoices')
.update({
status: 'sent',
sent_at: new Date().toISOString()
})
.update({ status: 'sent' })
.eq('id', id)
.eq('user_id', user.id)
@@ -159,8 +164,9 @@ 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) {
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
if (isRealInvoice && ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method)) {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
+134
View File
@@ -0,0 +1,134 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoice/pdf-template'
import { getVatRules } from '@/lib/invoice/vat-rules'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
/**
* POST /api/invoices/preview-pdf
*
* Generates a preview PDF from form data without creating an invoice.
* Returns the PDF as an inline blob for display in a new browser tab.
*/
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 = await request.json()
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body
if (!customer_id || !items || items.length === 0) {
return NextResponse.json({ error: 'Kunduppgifter och rader krävs' }, { status: 400 })
}
// Fetch customer
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('*')
.eq('id', customer_id)
.eq('user_id', user.id)
.single()
if (customerError || !customer) {
return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 })
}
// Fetch company settings
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
if (companyError || !company) {
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const docType: InvoiceDocumentType = document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
// Build items with line totals
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => ({
id: `preview-${index}`,
invoice_id: 'preview',
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: Math.round(item.quantity * item.unit_price * 100) / 100,
vat_rate: item.vat_rate ?? vatRules.rate,
vat_amount: 0,
created_at: new Date().toISOString(),
}))
const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0)
const vatAmount = isDeliveryNote ? 0 : Math.round(subtotal * (vatRules.rate / 100) * 100) / 100
const total = isDeliveryNote ? 0 : subtotal + vatAmount
// Construct a temporary Invoice-like object
const previewInvoice = {
id: 'preview',
user_id: user.id,
customer_id,
invoice_number: 'FÖRHANDSGRANSKNING',
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
due_date: due_date || new Date().toISOString().split('T')[0],
status: 'draft',
currency: currency || 'SEK',
exchange_rate: null,
exchange_rate_date: null,
subtotal: isDeliveryNote ? 0 : subtotal,
subtotal_sek: null,
vat_amount: vatAmount,
vat_amount_sek: null,
total,
total_sek: null,
vat_treatment: vatRules.treatment,
vat_rate: isDeliveryNote ? 0 : vatRules.rate,
moms_ruta: vatRules.momsRuta,
your_reference: your_reference || null,
our_reference: our_reference || null,
notes: notes || null,
reverse_charge_text: vatRules.reverseChargeText || null,
credited_invoice_id: null,
document_type: docType,
converted_from_id: null,
paid_at: null,
paid_amount: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Invoice
try {
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: previewInvoice,
customer: customer as Customer,
items: invoiceItems,
company: company as CompanySettings,
})
)
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline; filename="forhandsvisning.pdf"',
},
})
} catch (error) {
console.error('Preview PDF generation error:', error)
return NextResponse.json(
{ error: 'Kunde inte generera PDF-förhandsgranskning' },
{ status: 500 }
)
}
}
+77 -30
View File
@@ -2,8 +2,8 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import type { CreateInvoiceInput, EntityType, Invoice, CreditNote } from '@/types'
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
import type { CreateInvoiceInput, EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoice/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
createCreditNoteJournalEntry,
@@ -67,6 +67,7 @@ export async function POST(request: Request) {
}
const invoiceInput = body as CreateInvoiceInput
const documentType: InvoiceDocumentType = body.document_type || 'invoice'
// Get customer for VAT calculation
const { data: customer, error: customerError } = await supabase
@@ -80,16 +81,37 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
// Calculate VAT rules
// Calculate VAT rules (default for customer)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
// Calculate subtotal from items
// Calculate per-item VAT and subtotals
const subtotal = invoiceInput.items.reduce((sum, item) => {
return sum + item.quantity * item.unit_price
}, 0)
const vatAmount = calculateVat(subtotal, vatRules.rate)
const total = subtotal + vatAmount
// Calculate VAT per item, respecting per-line vat_rate
let vatAmount = 0
if (documentType !== 'delivery_note') {
for (const item of invoiceInput.items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
// Validate rate is allowed for this customer
if (!allowedRates.has(itemRate)) {
return NextResponse.json(
{ error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp` },
{ status: 400 }
)
}
const lineTotal = item.quantity * item.unit_price
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
}
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
// Determine if this is a mixed-rate invoice
const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
// Handle currency conversion
let exchangeRate: number | null = null
@@ -109,10 +131,15 @@ export async function POST(request: Request) {
}
}
// Generate invoice number
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
// Generate invoice number with appropriate prefix
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: user.id,
})
const invoiceNumber = documentType === 'proforma'
? `PF-${baseNumber}`
: documentType === 'delivery_note'
? `FS-${baseNumber}`
: baseNumber
// Create invoice
const { data: invoice, error: invoiceError } = await supabase
@@ -126,19 +153,20 @@ export async function POST(request: Request) {
currency: invoiceInput.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal,
subtotal_sek: subtotalSek,
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
total,
total_sek: totalSek,
total_sek: documentType === 'delivery_note' ? null : totalSek,
vat_treatment: vatRules.treatment,
vat_rate: vatRules.rate,
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
your_reference: invoiceInput.your_reference,
our_reference: invoiceInput.our_reference,
notes: invoiceInput.notes,
document_type: documentType,
})
.select()
.single()
@@ -147,16 +175,23 @@ export async function POST(request: Request) {
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
}
// Create invoice items
const items = invoiceInput.items.map((item, index) => ({
invoice_id: invoice.id,
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.quantity * item.unit_price,
}))
// Create invoice items with per-line VAT
const items = invoiceInput.items.map((item, index) => {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
return {
invoice_id: invoice.id,
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
}
})
const { error: itemsError } = await supabase
.from('invoice_items')
@@ -175,8 +210,8 @@ export async function POST(request: Request) {
.eq('id', invoice.id)
.single()
// Emit event (no journal entry at draft — booking happens at send/payment based on accounting method)
if (completeInvoice) {
// Emit event only for real invoices (proformas and delivery notes are informational)
if (completeInvoice && documentType === 'invoice') {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
@@ -204,6 +239,14 @@ async function createCreditNote(
return NextResponse.json({ error: 'Original invoice not found' }, { status: 404 })
}
// Credit notes can only be created from real invoices
if (originalInvoice.document_type && originalInvoice.document_type !== 'invoice') {
return NextResponse.json(
{ error: 'Credit notes can only be created from standard invoices' },
{ status: 400 }
)
}
// Check if invoice is already credited
if (originalInvoice.status === 'credited') {
return NextResponse.json({ error: 'Invoice has already been credited' }, { status: 400 })
@@ -258,8 +301,8 @@ async function createCreditNote(
return NextResponse.json({ error: creditNoteError.message }, { status: 500 })
}
// Create credit note items (negated from original)
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }, index: number) => ({
// Create credit note items (negated from original, preserving per-line VAT)
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number }) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
description: item.description,
@@ -267,6 +310,8 @@ async function createCreditNote(
unit: item.unit,
unit_price: item.unit_price,
line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 25,
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
}))
const { error: itemsError } = await supabase
@@ -292,17 +337,19 @@ async function createCreditNote(
.eq('id', creditNote.id)
.single()
// Fetch entity type for correct account mapping
// Fetch entity type and accounting method for correct account mapping
const { data: creditNoteSettings } = await supabase
.from('company_settings')
.select('entity_type')
.select('entity_type, accounting_method')
.eq('user_id', userId)
.single()
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual'
// Create journal entry for the credit note (non-blocking)
if (completeCreditNote) {
// Cash method: skip — no original invoice entry exists to reverse; deferred until refund
if (completeCreditNote && accountingMethod === 'accrual') {
try {
const journalEntry = await createCreditNoteJournalEntry(
userId,
+33
View File
@@ -0,0 +1,33 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
ensureInitialized()
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 = await request.json()
const { transaction_id, journal_entry_id } = body
if (!transaction_id || !journal_entry_id) {
return NextResponse.json(
{ error: 'transaction_id and journal_entry_id are required' },
{ status: 400 }
)
}
const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ data: { success: true } })
}
+45
View File
@@ -0,0 +1,45 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
ensureInitialized()
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 = await request.json()
const { date_from, date_to, dry_run } = body
const result = await runReconciliation(supabase, user.id, {
dateFrom: date_from,
dateTo: date_to,
dryRun: dry_run ?? false,
})
return NextResponse.json({
data: {
matches: result.matches.map((m) => ({
transaction_id: m.transaction.id,
transaction_date: m.transaction.date,
transaction_description: m.transaction.description,
transaction_amount: m.transaction.amount,
journal_entry_id: m.glLine.journal_entry_id,
voucher_number: m.glLine.voucher_number,
voucher_series: m.glLine.voucher_series,
entry_date: m.glLine.entry_date,
entry_description: m.glLine.entry_description,
method: m.method,
confidence: m.confidence,
})),
applied: result.applied,
errors: result.errors,
dry_run: dry_run ?? false,
},
})
}
@@ -0,0 +1,20 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-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 dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const status = await getReconciliationStatus(supabase, user.id, dateFrom, dateTo)
return NextResponse.json({ data: status })
}
@@ -0,0 +1,30 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
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 = await request.json()
const { transaction_id } = body
if (!transaction_id) {
return NextResponse.json(
{ error: 'transaction_id is required' },
{ status: 400 }
)
}
const result = await unlinkReconciliation(supabase, user.id, transaction_id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ data: { success: true } })
}
@@ -0,0 +1,20 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-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 dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const lines = await fetchUnlinkedGLLines(supabase, user.id, dateFrom, dateTo)
return NextResponse.json({ data: lines })
}
+12 -2
View File
@@ -4,7 +4,7 @@ import {
calculateVatDeclaration,
formatPeriodLabel,
} from '@/lib/reports/vat-declaration'
import type { VatPeriodType } from '@/types'
import type { VatPeriodType, AccountingMethod } from '@/types'
/**
* GET /api/reports/vat-declaration
@@ -90,12 +90,22 @@ export async function GET(request: Request) {
)
}
// 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 as AccountingMethod) || 'accrual'
try {
const declaration = await calculateVatDeclaration(
user.id,
periodType,
year,
period
period,
accountingMethod
)
return NextResponse.json({
+10
View File
@@ -42,6 +42,16 @@ export async function PUT(request: Request) {
const body = await request.json()
// Validate: enskild firma must use calendar year (BFL 3 kap.)
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) {
return NextResponse.json(
{ error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' },
{ status: 400 }
)
}
const { data, error } = await supabase
.from('company_settings')
.update(body)
+29 -17
View File
@@ -1,7 +1,7 @@
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'
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
export async function POST(
_request: Request,
@@ -88,24 +88,36 @@ export async function POST(
await supabase.from('supplier_invoice_items').insert(creditItems)
// Create credit note journal entry
// 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 as AccountingMethod) || 'accrual'
// Create credit note journal entry (accrual only)
// Cash method: skip — no original registration entry exists to reverse; deferred until refund
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)
if (accountingMethod === 'accrual') {
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)
}
} catch (err) {
console.error('Failed to create credit note journal entry:', err)
}
// Update original invoice: reduce remaining_amount
+7 -6
View File
@@ -1,6 +1,5 @@
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'
@@ -69,17 +68,19 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
}
// Calculate totals from items
// Calculate totals from items (supports both amount-based and legacy quantity*price)
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 lineTotal = item.amount != null
? Math.round(item.amount * 100) / 100
: Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 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,
quantity: item.amount != null ? 1 : (item.quantity ?? 1),
unit: item.amount != null ? 'st' : (item.unit || 'st'),
unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0),
line_total: lineTotal,
account_number: item.account_number,
vat_code: item.vat_code || null,
+22 -118
View File
@@ -1,58 +1,15 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import {
getRevenueAccount,
getOutputVatAccount,
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import type { Transaction, Invoice, CreateJournalEntryInput, EntityType, VatTreatment } from '@/types'
import type { EntityType, Invoice } from '@/types'
interface MatchInvoiceRequest {
invoice_id: string
}
/**
* Ensure a fiscal period exists for the given date, create one if needed
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
): Promise<string | null> {
// Check if a fiscal period already covers this date
const existingPeriodId = await findFiscalPeriod(userId, date)
if (existingPeriodId) {
return existingPeriodId
}
// No fiscal period exists - create one for the year of the transaction
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
const periodStart = `${year}-01-01`
const periodEnd = `${year}-12-31`
const { data, error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
period_start: periodStart,
period_end: periodEnd,
}, {
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-invoice
*
@@ -113,10 +70,10 @@ export async function POST(
)
}
// Fetch the invoice (validates ownership)
// Fetch the invoice with items (validates ownership, items needed for per-line VAT)
const { data: invoice, error: fetchInvError } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice_id)
.eq('user_id', user.id)
.single()
@@ -151,76 +108,23 @@ export async function POST(
let journalEntryError: string | null = null
try {
const fiscalPeriodId = await ensureFiscalPeriod(supabase, user.id, transaction.date)
if (fiscalPeriodId) {
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, entityType)
const lines: CreateJournalEntryInput['lines'] = [
{
account_number: '1930',
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
},
{
account_number: revenueAccount,
debit_amount: 0,
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)
journalEntryId = journalEntry.id
if (accountingMethod === 'cash') {
// Kontantmetoden: combined revenue entry with per-line VAT rates
const journalEntry = await createInvoiceCashEntry(
user.id,
invoice as Invoice,
transaction.date,
entityType
)
journalEntryId = journalEntry?.id ?? null
} else {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
user.id,
invoice as Invoice,
transaction.date
)
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
console.error('Failed to create payment journal entry:', err)