Fix/m sprint fixes (#613)

* fix(dashboard): exclude ignored and already-triaged transactions from stale count

The "Gamla transaktioner" widget counted transactions that had been ignored
or already marked as is_business=true but not yet booked, so users saw a
nag for a row they had already dealt with — and the /transactions inbox
correctly hid it. Align the count with the inbox criterion (is_business
IS NULL, is_ignored = false) so the widget clears when the row leaves
the inbox.

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

* fix(transactions): read entity_type from settings response wrapper

The transactions page read entityRes.entity_type directly, but
/api/settings returns { data: { entity_type, ... } }. The expression
was always undefined, so setEntityType never fired and entityType
stayed at its initial 'enskild_firma'. The template picker's
entity_type filter then dropped every aktiebolag-tagged user template
for AB customers — only entity_type='all' templates made it through.

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

* stale templates
bank sync
journal entry from transaction

* fixed pr comments

* fixed pr comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-30 01:28:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fc7a46c3f2
commit ea1bf01f1e
76 changed files with 5474 additions and 532 deletions
@@ -29,6 +29,7 @@ function makeTx(overrides: Partial<Transaction> = {}): Transaction {
receipt_id: null,
document_id: null,
reconciliation_method: null,
is_ignored: false,
import_source: 'enable_banking',
reference: null,
counterparty_iban: 'SE9550000000054910000003',
@@ -0,0 +1,159 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { isPaymentSourceType, syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { JournalEntry } from '@/types'
describe('isPaymentSourceType', () => {
it.each([
'invoice_paid',
'invoice_cash_payment',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
])('recognises %s as payment', (sourceType) => {
expect(isPaymentSourceType(sourceType)).toBe(true)
})
it.each(['manual', 'invoice_created', 'supplier_invoice_registered', '', null, undefined])(
'rejects %s',
(sourceType) => {
expect(isPaymentSourceType(sourceType)).toBe(false)
}
)
})
describe('syncInvoiceStatusFromPaymentEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function entry(overrides: Partial<JournalEntry> = {}): Pick<JournalEntry, 'id' | 'source_type' | 'source_id'> {
return {
id: 'entry-1',
source_type: 'supplier_invoice_paid',
source_id: 'supplier-invoice-1',
...overrides,
} as Pick<JournalEntry, 'id' | 'source_type' | 'source_id'>
}
it('is a no-op when source_type is not a payment', async () => {
const { supabase } = createQueuedMockSupabase()
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'manual' as JournalEntry['source_type'] })
)
expect(supabase.from).not.toHaveBeenCalled()
})
it('is a no-op when source_id is missing', async () => {
const { supabase } = createQueuedMockSupabase()
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_id: null })
)
expect(supabase.from).not.toHaveBeenCalled()
})
it('reverts a fully-paid supplier invoice back to approved', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
// Fully paid before deletion: paid_amount === total_amount
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
{ data: null }, // UPDATE result
])
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual([
'supplier_invoice_payments',
'supplier_invoices',
'supplier_invoices',
])
})
it('reverts a partially-paid supplier invoice to partially_paid when paid_amount remains', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 500 } }, // payment being reversed
// Started with 1000 paid (multiple payments), reversing 500
{ data: { paid_amount: 1000, total_amount: 1500, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
// Test passes if the queries fire in the expected order without error
expect((supabase.from as ReturnType<typeof vi.fn>).mock.calls.length).toBe(3)
})
it('routes customer invoice entries through the invoices table', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
{ data: { paid_amount: 1000, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'invoice_paid', source_id: 'invoice-1' })
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['invoice_payments', 'invoices', 'invoices'])
})
it('handles invoice_cash_payment the same way as invoice_paid', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 500 } },
{ data: { paid_amount: 500, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' })
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls[0]).toBe('invoice_payments')
expect(fromCalls[1]).toBe('invoices')
})
it('handles supplier_invoice_cash_payment the same way as supplier_invoice_paid', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'supplier_invoice_cash_payment' })
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls[0]).toBe('supplier_invoice_payments')
expect(fromCalls[1]).toBe('supplier_invoices')
})
it('does not error when no payment row exists for the supplier entry', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null }, // no payment row
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
])
await expect(
syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
).resolves.toBeUndefined()
})
})
@@ -877,6 +877,43 @@ describe('createSupplierInvoicePaymentEntry', () => {
expect(input.description).toBe('Utbetalning leverantörsfaktura LF-200, Leverantör AB (ankomst 10)')
})
it('credits the provided paymentAccount instead of 1930', async () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01',
undefined, undefined, '1940'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
expect(findByAccount(input.lines, '1940')[0].credit_amount).toBe(10000)
})
it('falls back to 1930 when paymentAccount is undefined', async () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(10000)
})
it('uses paymentAccount on the FX-difference branch too', async () => {
const invoice = makeSupplierInvoice({ total: 11500, currency: 'EUR' })
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 11500, '2024-07-15',
500, undefined, '2018'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
expect(findByAccount(input.lines, '2018')[0].credit_amount).toBe(11000)
})
it('uses paymentDate not invoice_date as entry_date', async () => {
const invoice = makeSupplierInvoice({ invoice_date: '2024-06-01' })
@@ -936,6 +973,23 @@ describe('createSupplierInvoiceCashEntry', () => {
assertBalanced(input)
})
it('credits the provided paymentAccount instead of 1930', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000, vat_amount: 2000, total: 10000,
})
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business',
undefined, '2018'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
expect(findByAccount(input.lines, '2018')[0].credit_amount).toBe(10000)
assertBalanced(input)
})
it('domestic zero VAT', async () => {
const invoice = makeSupplierInvoice({
subtotal: 5000,