feat: BFL-compliant journal descriptions and cancelled entry status (#55)

* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2)

Reversed entries (storno) must appear alongside their original posted entries
in reports for a complete audit trail. Previously, filtering by status='posted'
excluded them, causing discrepancies when corrections had been made.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: semi-manual invoice payment booking with editable journal lines

When marking an invoice as paid, users now see a dialog where they can:
- Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.)
- Review and edit the proposed journal entry lines before committing
- The happy path remains fast — lines are pre-filled correctly

Implementation:
- Pure proposePaymentLines() function for line computation (accrual + cash)
- PaymentBookingDialog with AccountCombobox, balance validation, date picker
- API accepts optional custom lines, falls back to auto-generation without them
- 18 tests (8 unit + 10 API) all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — validation fallback, balance check, error handling

- P1: Return 400 on invalid body instead of silently falling back to
  auto-generated lines (split JSON parse from schema validation)
- P1: Add server-side balance check for custom lines before committing
  (debit must equal credit, totalDebit > 0)
- P2: Wrap PaymentBookingDialog init() in try/catch with toast on
  failure and auto-close instead of silent empty state
- Add 2 new tests: unbalanced lines → 400, invalid schema → 400

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: OAuth callback redirect for local dev and timeout resilience

- Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth
  callbacks work on localhost (not just production)
- Encode consentId/provider in OAuth state (base64url JSON) so the
  callback doesn't depend on session storage
- Add skipAuth flag to extension API routes for OAuth callbacks
  (external provider redirects have no user session cookie)
- Wrap AbortError in descriptive timeout messages in arcim-client
- Make preview endpoint resilient to partial failures (company info
  and SIE fetch are individually non-blocking)
- Simplify login page (remove unused magic link auth mode)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: create journal entry before marking invoice as paid

Move journal entry creation before the invoice status update so that
if accounting fails, the invoice is not permanently marked paid without
a corresponding entry. Previously the error was silently swallowed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update mark-paid tests for journal-first ordering

Reorder mock queue to match new flow (settings before update), update
failure test to expect 500 instead of silent success, add try-catch
with proper error response in route handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add reverse charge VAT (ruta 20-32) and improve mobile UX across dashboard

Add full reverse charge (omvänd skattskyldighet) support to the VAT declaration:
- Map accounts 2614/2624/2634 to ruta 30/31/32 for self-assessed output VAT
- Calculate purchase bases (ruta 20-24) from supplier invoices by supplier type
- Include ruta 30-32 in ruta 49 formula and totalOutputVat summary
- Display reverse charge section in reports UI and composition chart
- Add comprehensive test coverage for all reverse charge scenarios

Improve mobile UX across the app:
- Convert nav drawer to bottom sheet with drag handle and safe area padding
- Add mobile card layout for PaymentBookingDialog journal lines
- Replace settings tab pills with dropdown selector on mobile
- Make wizard step indicators responsive (collapsed on mobile)
- Ensure all dialog footers stack buttons full-width on mobile
- Add 44px minimum touch targets throughout
- Make onboarding buttons full-width on mobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — indentation, query efficiency, tab dedup

- Fix misleading try-block indentation in mark-paid route
- Filter reversed entries at DB level (.eq('status', 'posted')) instead
  of fetching then discarding in memory
- Extract shared settingsTabs array so mobile Select and desktop
  TabsList stay in sync automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add resilience fallbacks, Arcim retry logic, and client tests

Add FallbackPrompt component and integrate it across banking and migration
error states so users always have a manual import escape hatch. Add retry
with exponential backoff to Arcim API client for transient failures (429,
502, 503, 504) and timeouts. Expand import page deep-linking with ?mode=
parameter. Add persistent error banner on settings page for bank connection
failures. Include 18 new tests for the Arcim client covering retry, backoff,
pagination, timeout, env validation, and singleton resource unwrapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — setActiveTab, test cleanup, redundant clearTimeout

- Add missing setActiveTab('banking') when handling bank_error query
  param so the error banner is actually visible (P1)
- Guard env-var cleanup with try/finally in arcim-client tests to
  prevent state leakage on assertion failure (P2)
- Only mock retry-range setTimeout delays in backoff test, letting
  AbortController timers pass through real setTimeout (P2)
- Remove redundant clearTimeout in catch block — finally handles it (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add BFL-compliant counterparty names to journal descriptions and cancelled entry status

Journal descriptions now include customer/supplier names for traceability
(e.g. "Kundfaktura 1001, Foretag AB"). Failed draft entries are marked as
'cancelled' instead of deleted, respecting immutability constraints.
Includes DB migration for the new journal_entries status value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — Swedish typos, missing source type, trigger and reversal cleanup

- Fix Swedish spelling: leverantor → leverantör in all supplier description prefixes
- Add supplier_credit_note to supplierSourceTypes in VAT declaration so credit
  notes correctly reduce reverse-charge bases (ruta 20–24)
- Mark orphaned concurrent reversals as cancelled instead of attempting deletion
  that the immutability trigger blocks
- Allow posted → cancelled transition in trigger for orphaned reversal cleanup
- Restrict cancelled entry line trigger to DELETE-only (block INSERT/UPDATE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use main's Step3TaxRegistration (onboarding restructured in PR #54)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: retrigger Greptile review

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-18 17:53:54 +01:00
committed by GitHub
parent 08e1757798
commit 86348c80c7
18 changed files with 406 additions and 45 deletions
@@ -140,7 +140,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
'user-1',
expect.objectContaining({ id: 'inv-1' }),
expect.any(String),
undefined
undefined,
expect.anything()
)
})
@@ -174,7 +175,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
'user-1',
expect.objectContaining({ id: 'inv-1' }),
expect.any(String),
'enskild_firma'
'enskild_firma',
expect.anything()
)
})
+7 -3
View File
@@ -118,7 +118,9 @@ export async function POST(
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: `Betalning faktura ${invoice.invoice_number}`,
description: invoice.customer?.name
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
: `Inbetalning kundfaktura ${invoice.invoice_number}`,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
@@ -132,7 +134,8 @@ export async function POST(
user.id,
invoice as Invoice,
paymentDate,
exchangeRateDifference
exchangeRateDifference,
invoice.customer?.name
)
journalEntryId = journalEntry?.id ?? null
} else {
@@ -142,7 +145,8 @@ export async function POST(
user.id,
invoice as Invoice,
paymentDate,
entityType
entityType,
invoice.customer?.name
)
journalEntryId = journalEntry?.id ?? null
}
+2 -1
View File
@@ -74,7 +74,8 @@ export async function POST(
supabase,
user.id,
invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma'
(settings?.entity_type as EntityType) || 'enskild_firma',
invoice.customer?.name
)
if (journalEntry) {
journalEntryId = journalEntry.id
+2 -1
View File
@@ -387,7 +387,8 @@ async function createCreditNote(
supabase,
userId,
completeCreditNote as Invoice,
entityType
entityType,
completeCreditNote.customer?.name
)
if (journalEntry) {
await supabase
@@ -111,7 +111,8 @@ export async function POST(
user.id,
creditNote as SupplierInvoice,
creditItems as SupplierInvoiceItem[],
original.supplier?.supplier_type || 'swedish_business'
original.supplier?.supplier_type || 'swedish_business',
original.supplier?.name
)
if (journalEntry) {
journalEntryId = journalEntry.id
@@ -72,7 +72,8 @@ export async function POST(
invoice as SupplierInvoice,
(invoice.items || []) as SupplierInvoiceItem[],
paymentDate,
invoice.supplier?.supplier_type || 'swedish_business'
invoice.supplier?.supplier_type || 'swedish_business',
invoice.supplier?.name
)
if (journalEntry) journalEntryId = journalEntry.id
} else {
@@ -82,7 +83,8 @@ export async function POST(
invoice as SupplierInvoice,
paymentAmount,
paymentDate,
body.exchange_rate_difference
body.exchange_rate_difference,
invoice.supplier?.name
)
if (journalEntry) journalEntryId = journalEntry.id
}
+2 -1
View File
@@ -172,7 +172,8 @@ export async function POST(request: Request) {
user.id,
invoice as SupplierInvoice,
items as SupplierInvoiceItem[],
supplier.supplier_type
supplier.supplier_type,
supplier.name
)
if (journalEntry) {
registrationJournalEntryId = journalEntry.id
@@ -190,7 +190,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect.anything(),
'user-1',
expect.objectContaining({ id: VALID_UUID }),
'2024-06-15'
'2024-06-15',
undefined,
expect.anything()
)
})
@@ -110,7 +110,8 @@ export async function POST(
user.id,
invoice as Invoice,
transaction.date,
entityType
entityType,
invoice.customer?.name
)
journalEntryId = journalEntry?.id ?? null
} else {
@@ -119,7 +120,9 @@ export async function POST(
supabase,
user.id,
invoice as Invoice,
transaction.date
transaction.date,
undefined,
invoice.customer?.name
)
journalEntryId = journalEntry?.id ?? null
}
+92 -3
View File
@@ -1,6 +1,29 @@
import { describe, it, expect } from 'vitest'
import { validateBalance, getSwedishLocalDate } from '../engine'
import type { CreateJournalEntryLineInput } from '@/types'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry } from '../engine'
import type { CreateJournalEntryLineInput, JournalEntryStatus } from '@/types'
// Mock Supabase client for createDraftEntry/reverseEntry tests
function createMockChain(overrides: Record<string, unknown> = {}) {
const chain: Record<string, unknown> = {
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: overrides.singleData ?? null, error: overrides.singleError ?? null }),
eq: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
}
return chain
}
// Mock event bus
vi.mock('@/lib/events', () => ({
eventBus: { emit: vi.fn().mockResolvedValue([]) },
}))
describe('validateBalance', () => {
it('balanced entry (debit == credit) → valid: true', () => {
@@ -75,3 +98,69 @@ describe('getSwedishLocalDate', () => {
expect(parsed.toString()).not.toBe('Invalid Date')
})
})
describe('createDraftEntry — cancelled status on line-insert failure', () => {
it('sets status to cancelled (not delete) when line insert fails', async () => {
const updateMock = vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) })
const supabase = {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'journal_entries') {
return {
insert: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'entry-1', user_id: 'user-1', status: 'draft' as JournalEntryStatus },
error: null,
}),
}),
}),
update: updateMock,
delete: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }),
}
}
if (table === 'journal_entry_lines') {
return {
insert: vi.fn().mockResolvedValue({ error: { message: 'Line insert failed' } }),
}
}
if (table === 'chart_of_accounts') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
in: vi.fn().mockResolvedValue({
data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }],
error: null,
}),
}),
}),
}
}
return createMockChain()
}),
}
await expect(
createDraftEntry(supabase as never, 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2024-01-01',
description: 'Test',
source_type: 'manual',
lines: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
],
})
).rejects.toThrow('Failed to create journal entry lines')
// Should call update with cancelled status, NOT delete
expect(updateMock).toHaveBeenCalledWith({ status: 'cancelled' })
})
})
describe('JournalEntryStatus type includes cancelled', () => {
it('cancelled is a valid JournalEntryStatus value', () => {
const status: JournalEntryStatus = 'cancelled'
expect(['draft', 'posted', 'reversed', 'cancelled']).toContain(status)
})
})
@@ -490,6 +490,89 @@ describe('createInvoiceJournalEntry — EUR foreign currency', () => {
})
})
describe('BFL-compliant descriptions with counterparty names', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('createInvoiceJournalEntry includes customer name in description', async () => {
const invoice = makeInvoice({
items: [makeItem()],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice, 'enskild_firma', 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Kundfaktura 1001, Foretag AB')
})
it('createInvoiceJournalEntry falls back without customer name', async () => {
const invoice = makeInvoice({
items: [makeItem()],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice, 'enskild_firma')
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Kundfaktura 1001')
})
it('createInvoicePaymentJournalEntry includes customer name', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15', undefined, 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Inbetalning kundfaktura 1001, Foretag AB')
})
it('createInvoicePaymentJournalEntry falls back without customer name', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Inbetalning kundfaktura 1001')
})
it('createCreditNoteJournalEntry includes customer name', async () => {
const creditNote = makeInvoice({
invoice_number: 'KR-1001',
subtotal: -1000,
vat_amount: -250,
total: -1250,
items: [makeItem({ quantity: -1, line_total: -1000, vat_amount: -250 })],
})
await createCreditNoteJournalEntry(null as never, 'user-1', creditNote, 'enskild_firma', 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Kreditfaktura KR-1001, Foretag AB')
})
it('createInvoiceCashEntry includes customer name', async () => {
const invoice = makeInvoice({
items: [makeItem()],
})
await createInvoiceCashEntry(null as never, 'user-1', invoice, '2024-07-01', 'enskild_firma', 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Kontantbetalning kundfaktura 1001, Foretag AB')
})
it('createInvoiceCashEntry falls back without customer name', async () => {
const invoice = makeInvoice({
items: [makeItem()],
})
await createInvoiceCashEntry(null as never, 'user-1', invoice, '2024-07-01', 'enskild_firma')
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Kontantbetalning kundfaktura 1001')
})
})
describe('createInvoicePaymentJournalEntry — exchange rate difference', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -368,6 +368,36 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
expect(input.description).toContain('42')
})
it('description includes supplier name when provided', async () => {
const invoice = makeSupplierInvoice({
supplier_invoice_number: 'LF-100',
arrival_number: 5,
})
const items = [makeItem()]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business', 'Leverantör AB'
)
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Leverantörsfaktura LF-100, Leverantör AB (ankomst 5)')
})
it('description falls back without supplier name', async () => {
const invoice = makeSupplierInvoice({
supplier_invoice_number: 'LF-100',
arrival_number: 5,
})
const items = [makeItem()]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Leverantörsfaktura LF-100 (ankomst 5)')
})
it('handles non-EU reverse charge (services)', async () => {
const invoice = makeSupplierInvoice({
subtotal: 5000,
@@ -553,6 +583,20 @@ describe('createSupplierInvoicePaymentEntry', () => {
expect(input.source_id).toBe('si-pay-1')
})
it('description includes supplier name when provided', async () => {
const invoice = makeSupplierInvoice({
supplier_invoice_number: 'LF-200',
arrival_number: 10,
})
await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 10000, '2024-07-01', undefined, 'Leverantör AB'
)
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Utbetalning leverantörsfaktura LF-200, Leverantör AB (ankomst 10)')
})
it('uses paymentDate not invoice_date as entry_date', async () => {
const invoice = makeSupplierInvoice({ invoice_date: '2024-06-01' })
@@ -705,8 +749,20 @@ describe('createSupplierInvoiceCashEntry', () => {
expect(input.source_id).toBe('si-cash-1')
})
it('description contains "kontantmetoden"', async () => {
const invoice = makeSupplierInvoice()
it('description includes supplier name when provided', async () => {
const invoice = makeSupplierInvoice({ supplier_invoice_number: 'LF-300' })
const items = [makeItem()]
await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business', 'Leverantör AB'
)
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Kontantbetalning leverantörsfaktura LF-300, Leverantör AB')
})
it('description falls back without supplier name', async () => {
const invoice = makeSupplierInvoice({ supplier_invoice_number: 'LF-300' })
const items = [makeItem()]
await createSupplierInvoiceCashEntry(
@@ -714,7 +770,7 @@ describe('createSupplierInvoiceCashEntry', () => {
)
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toContain('kontantmetoden')
expect(input.description).toBe('Kontantbetalning leverantörsfaktura LF-300')
})
})
@@ -862,6 +918,24 @@ describe('createSupplierCreditNoteEntry', () => {
expect(input.lines[0].account_number).toBe('2440')
})
it('description includes supplier name when provided', async () => {
const creditNote = makeSupplierInvoice({
is_credit_note: true,
supplier_invoice_number: 'LF-400',
arrival_number: 7,
total: -10000,
vat_amount: -2000,
})
const items = [makeItem({ line_total: -8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'swedish_business', 'Leverantör AB'
)
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.description).toBe('Kreditfaktura leverantör LF-400, Leverantör AB (ankomst 7)')
})
it('sets source_type to supplier_credit_note', async () => {
const creditNote = makeSupplierInvoice({ id: 'si-cn-1', is_credit_note: true })
const items = [makeItem()]
+6 -7
View File
@@ -184,7 +184,7 @@ export async function createDraftEntry(
.insert(lineInserts)
if (linesError) {
await supabase.from('journal_entries').delete().eq('id', entry.id)
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', entry.id)
throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
}
@@ -378,7 +378,7 @@ export async function reverseEntry(
.insert(lineInserts)
if (linesError) {
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
throw new Error(`Failed to create reversal lines: ${linesError.message}`)
}
@@ -389,8 +389,7 @@ export async function reverseEntry(
.eq('id', reversalEntry.id)
if (postError) {
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
throw new Error(`Failed to post reversal entry: ${postError.message}`)
}
@@ -406,9 +405,9 @@ export async function reverseEntry(
.select('id')
if (casError || !updatedOriginal || updatedOriginal.length === 0) {
// Another concurrent reversal already changed the status — roll back our reversal
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
// Another concurrent reversal already changed the status — mark the orphaned
// reversal as cancelled so it's excluded from reports but remains traceable.
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
throw new Error('Entry was already reversed by a concurrent operation')
}
+25 -9
View File
@@ -16,6 +16,18 @@ import type {
const log = createLogger('invoice-entries')
/**
* Build a BFL-compliant verifikation description with event type and counterparty.
* Falls back to prefix + invoiceNumber if name is not provided (backward compat).
*/
function buildInvoiceDescription(
prefix: string, invoiceNumber: string, counterpartyName?: string
): string {
return counterpartyName
? `${prefix} ${invoiceNumber}, ${counterpartyName}`
: `${prefix} ${invoiceNumber}`
}
/**
* Group invoice items by VAT rate and generate per-rate revenue + VAT lines.
* Returns credit lines only (revenue + VAT). The caller adds the debit side.
@@ -142,7 +154,8 @@ export async function createInvoiceJournalEntry(
supabase: SupabaseClient,
userId: string,
invoice: Invoice,
entityType: EntityType = 'enskild_firma'
entityType: EntityType = 'enskild_firma',
customerName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, invoice.invoice_date)
if (!fiscalPeriodId) {
@@ -213,7 +226,7 @@ export async function createInvoiceJournalEntry(
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: `Faktura ${invoice.invoice_number}`,
description: buildInvoiceDescription('Kundfaktura', invoice.invoice_number, customerName),
source_type: 'invoice_created',
source_id: invoice.id,
lines,
@@ -233,7 +246,8 @@ export async function createInvoicePaymentJournalEntry(
userId: string,
invoice: Invoice,
paymentDate: string,
exchangeRateDifference?: number
exchangeRateDifference?: number,
customerName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
@@ -241,7 +255,7 @@ export async function createInvoicePaymentJournalEntry(
return null
}
const desc = `Betalning faktura ${invoice.invoice_number}`
const desc = buildInvoiceDescription('Inbetalning kundfaktura', invoice.invoice_number, customerName)
const bookedSekAmount = resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
const lines: CreateJournalEntryLineInput[] = []
@@ -326,7 +340,8 @@ export async function createCreditNoteJournalEntry(
supabase: SupabaseClient,
userId: string,
creditNote: Invoice,
entityType: EntityType = 'enskild_firma'
entityType: EntityType = 'enskild_firma',
customerName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, creditNote.invoice_date)
if (!fiscalPeriodId) {
@@ -391,7 +406,7 @@ export async function createCreditNoteJournalEntry(
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: creditNote.invoice_date,
description: `Kreditfaktura ${creditNote.invoice_number}`,
description: buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName),
source_type: 'credit_note',
source_id: creditNote.id,
lines,
@@ -413,7 +428,8 @@ export async function createInvoiceCashEntry(
userId: string,
invoice: Invoice,
paymentDate: string,
entityType: EntityType = 'enskild_firma'
entityType: EntityType = 'enskild_firma',
customerName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
@@ -462,7 +478,7 @@ export async function createInvoiceCashEntry(
account_number: '1930',
debit_amount: isForeign ? Math.round(totalCredits * 100) / 100 : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate),
credit_amount: 0,
line_description: `Betalning faktura ${invoice.invoice_number}`,
line_description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName),
})
lines.push(...creditLines)
@@ -470,7 +486,7 @@ export async function createInvoiceCashEntry(
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: `Betalning faktura ${invoice.invoice_number} (kontantmetoden)`,
description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName),
source_type: 'invoice_cash_payment',
source_id: invoice.id,
lines,
+25 -8
View File
@@ -13,6 +13,19 @@ import type {
const log = createLogger('supplier-invoice-entries')
/**
* Build a BFL-compliant verifikation description with event type, counterparty, and suffix.
* Falls back to prefix + invoiceNumber + suffix if name is not provided (backward compat).
*/
function buildSupplierDescription(
prefix: string, invoiceNumber: string, supplierName?: string, suffix?: string
): string {
const base = supplierName
? `${prefix} ${invoiceNumber}, ${supplierName}`
: `${prefix} ${invoiceNumber}`
return suffix ? `${base} ${suffix}` : base
}
/**
* Create journal entry when a supplier invoice is registered (accrual method)
*
@@ -35,7 +48,8 @@ export async function createSupplierInvoiceRegistrationEntry(
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string
supplierType: string,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, invoice.invoice_date)
if (!fiscalPeriodId) {
@@ -44,7 +58,7 @@ export async function createSupplierInvoiceRegistrationEntry(
}
const lines: CreateJournalEntryLineInput[] = []
const desc = `Lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomst ${invoice.arrival_number})`)
const isForeign = invoice.currency !== 'SEK'
// Aggregate expense amounts by account number and convert to SEK
@@ -134,7 +148,8 @@ export async function createSupplierInvoicePaymentEntry(
invoice: SupplierInvoice,
paymentAmount: number,
paymentDate: string,
exchangeRateDifference?: number
exchangeRateDifference?: number,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
@@ -142,7 +157,7 @@ export async function createSupplierInvoicePaymentEntry(
return null
}
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
const desc = buildSupplierDescription('Utbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomst ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
if (exchangeRateDifference && exchangeRateDifference !== 0) {
@@ -227,7 +242,8 @@ export async function createSupplierInvoiceCashEntry(
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
paymentDate: string,
supplierType: string
supplierType: string,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
@@ -235,7 +251,7 @@ export async function createSupplierInvoiceCashEntry(
return null
}
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (kontantmetoden)`
const desc = buildSupplierDescription('Kontantbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName)
const lines: CreateJournalEntryLineInput[] = []
// Aggregate expense amounts by account number and convert to SEK
@@ -317,7 +333,8 @@ export async function createSupplierCreditNoteEntry(
userId: string,
creditNote: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string
supplierType: string,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, creditNote.invoice_date)
if (!fiscalPeriodId) {
@@ -325,7 +342,7 @@ export async function createSupplierCreditNoteEntry(
return null
}
const desc = `Kreditfaktura lev. ${creditNote.supplier_invoice_number} (ankomst ${creditNote.arrival_number})`
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomst ${creditNote.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Credit: Expense accounts (reverse, in SEK)
+1
View File
@@ -285,6 +285,7 @@ async function calculateReverseChargeBases(
const supplierSourceTypes = [
'supplier_invoice_registered',
'supplier_invoice_cash_payment',
'supplier_credit_note',
]
const entries = await fetchAllRows<{
id: string
@@ -0,0 +1,65 @@
-- Add 'cancelled' status to journal entries for BFL varaktighet compliance.
-- Once a row is inserted into journal_entries, it must remain traceable.
-- Application code uses status='cancelled' instead of DELETE.
-- 1. Expand status CHECK to include 'cancelled'
ALTER TABLE public.journal_entries
DROP CONSTRAINT IF EXISTS journal_entries_status_check;
ALTER TABLE public.journal_entries
ADD CONSTRAINT journal_entries_status_check
CHECK (status IN ('draft', 'posted', 'reversed', 'cancelled'));
-- 2. Update immutability trigger: block all DELETEs, allow draft->cancelled
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
-- No exemption for drafts: varaktighet applies from insertion.
-- Application code uses status='cancelled' instead of DELETE.
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
-- Draft can transition to draft (update fields), posted, or cancelled
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
RETURN NEW;
END IF;
-- Posted can transition to reversed (storno) or cancelled (orphaned concurrent reversal cleanup)
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END; $$;
-- 3. Update line immutability: allow operations on cancelled parent entries
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v_status text;
BEGIN
SELECT status INTO v_status FROM public.journal_entries
WHERE id = COALESCE(OLD.journal_entry_id, NEW.journal_entry_id);
-- Draft entries: all operations allowed
IF v_status = 'draft' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
-- Cancelled entries: only DELETE for cleanup
IF v_status = 'cancelled' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RAISE EXCEPTION 'Cannot % lines of a cancelled journal entry.', TG_OP;
END IF;
RAISE EXCEPTION 'Cannot % lines of a % journal entry.', TG_OP, v_status;
END; $$;
+1 -1
View File
@@ -763,7 +763,7 @@ export type JournalEntrySourceType =
| 'currency_revaluation'
// Journal entry status
export type JournalEntryStatus = 'draft' | 'posted' | 'reversed'
export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' | 'cancelled'
// Mapping rule type
export type MappingRuleType =