feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)
* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium (SLF 1991:687). The item-based debit-only form could not express the self-balancing pair, so users had to hand-edit the verifikat. - new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source, re-exported by the bokslut calculator), isSlpPensionAccount (741x), generateSlpLines (7533 D / 2514 K, nets to zero) - migration adds supplier_invoice_items.apply_slp boolean default false - registration, cash, and privately-paid generators inject the pair for flagged 741x items, mirroring the reverse-charge injection; the balance guarantees keep 2440/1930/2893 at exactly the invoice total; the credit note generator reverses the pair (7533 K / 2514 D) - privately-paid balance guarantee now subtracts existing credits so the SLP 2514 leg never inflates the owner account - schema field apply_slp + guards in all create paths (main route, inbox convert, v1 REST, pending-operations executor): 400 SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400 SI_CREATE_SLP_ACCRUAL combined with periodisering - form: advisory hint on unflagged 741x rows with one-click opt-in and a quiet confirmation line when applied; totals box untouched (the invoice total stays the payable); AB review preview injects the same pair via the same generator for parity - year-end double-count guard: calculateSarskildLoneskatt subtracts SLP already posted to 7533 during the year (floored at zero) so bokslut never provisions flagged premiums twice Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(api-skill): regenerate suppliers reference for apply_slp The apiskill:check CI gate requires the generated accounted-api skill to stay in sync with the endpoint registry after the apply_slp addition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal Review findings on the SLP PR: - v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514 pair booked at registration (it previously stood forever and the year-end netting under-provisioned). The flag is also copied onto the created credit-note items for parity with the web credit route. - v1 mark-paid: the items sub-select now includes apply_slp, so a kontantmetoden payment via v1 books the cash entry WITH the SLP pair, matching the web mark-paid. - v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag is readable back through the public API. - credit-note SLP base is abs of the SIGNED sum of flagged line_totals, not per-item abs: a mixed-sign flagged original (+10000/-2000) booked SLP on 8000 at registration and now reverses exactly that, not 12000. The expense-bucket per-item abs convention is untouched. - kontantmetod bank-match preview appends the same generateSlpLines pair the POST books, so the approved lines equal the committed lines. - MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts apply_slp (optional boolean), plumbs it into the staged operation's items, and rejects non-741x resolved accounts at staging time with the bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts. - DECISIONS.md: five entries for today's decisions. Every behavioral fix has a test verified to fail without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
3a7688c163
commit
7cf0e34434
@@ -92,6 +92,9 @@ export const POST = withRouteContext(
|
||||
// Preserve the self-assessed RC rate so the credit-note verifikat
|
||||
// reverses fiktiv moms at the same rate the original was booked at.
|
||||
reverse_charge_rate: item.reverse_charge_rate,
|
||||
// Preserve the SLP flag for display parity; the journal reversal reads
|
||||
// the ORIGINAL items, so the 7533/2514 swap is correct either way.
|
||||
apply_slp: item.apply_slp ?? false,
|
||||
// Dims copied for display parity; the journal reversal reads the
|
||||
// ORIGINAL items below (dimensions PR7).
|
||||
dimensions: item.dimensions ?? {},
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
makeSupplier,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
@@ -1142,3 +1142,123 @@ describe('POST /api/supplier-invoices: exchange rate + SEK amounts', () => {
|
||||
expect(supplierInvoiceInsert()!.exchange_rate).toBe(99999.99)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Särskild löneskatt (SLP, apply_slp) ─────────────────────────────────────
|
||||
|
||||
describe('POST /api/supplier-invoices: särskild löneskatt (apply_slp)', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function slpBody(items: Record<string, unknown>[]) {
|
||||
return {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'LF-SLP',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('rejects apply_slp on a non-741x account with SI_CREATE_SLP_INVALID_ACCOUNT', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: slpBody([
|
||||
{ description: 'Konsult', amount: 10000, account_number: '6200', vat_rate: 0.25, apply_slp: true },
|
||||
]),
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_SLP_INVALID_ACCOUNT')
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects apply_slp combined with periodisering on the same item', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: slpBody([
|
||||
{
|
||||
description: 'Tjänstepension',
|
||||
amount: 10000,
|
||||
account_number: '7412',
|
||||
vat_rate: 0,
|
||||
apply_slp: true,
|
||||
accrual_period_start: '2024-06-01',
|
||||
accrual_period_end: '2024-12-31',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_SLP_ACCRUAL')
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('happy path: apply_slp on a 7412 line is stored on the item and reaches the generator', async () => {
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup
|
||||
enqueue({ data: 9 }) // get_next_arrival_number
|
||||
enqueue({ data: makeSupplierInvoice({ id: 'si-slp' }), error: null }) // insert invoice
|
||||
enqueue({ data: [], error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null }) // settings
|
||||
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-slp' })
|
||||
enqueue({ data: null, error: null }) // update registration_journal_entry_id
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: slpBody([
|
||||
{ description: 'Avanza tjänstepension', amount: 10000, account_number: '7412', vat_rate: 0, apply_slp: true },
|
||||
]),
|
||||
}),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(200)
|
||||
|
||||
// The DB insert carries the flag...
|
||||
const itemsInsert = findCall('supplier_invoice_items', 'insert')
|
||||
expect(itemsInsert).toBeDefined()
|
||||
const rows = itemsInsert![0] as Array<Record<string, unknown>>
|
||||
expect(rows[0].apply_slp).toBe(true)
|
||||
expect(rows[0].account_number).toBe('7412')
|
||||
|
||||
// ...and the same items array reaches the registration generator, which
|
||||
// injects the 7533/2514 pair from it.
|
||||
const generatorItems = mockCreateSupplierInvoiceRegistrationEntry.mock
|
||||
.calls[0][4] as Array<Record<string, unknown>>
|
||||
expect(generatorItems[0].apply_slp).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults apply_slp to false when omitted', async () => {
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
|
||||
enqueue({ data: 10 })
|
||||
enqueue({ data: makeSupplierInvoice({ id: 'si-noslp' }), error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: slpBody([
|
||||
{ description: 'Pensionspremie utan SLP-flagga', amount: 5000, account_number: '7412', vat_rate: 0 },
|
||||
]),
|
||||
}),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(200)
|
||||
|
||||
const itemsInsert = findCall('supplier_invoice_items', 'insert')
|
||||
const rows = itemsInsert![0] as Array<Record<string, unknown>>
|
||||
expect(rows[0].apply_slp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
@@ -122,6 +123,23 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
// Särskild löneskatt (SLP): the 7533/2514 pair is only lawful on pension
|
||||
// premiums, so the flag is rejected on any non-741x account, and rejected
|
||||
// together with periodisering on the same row (the pair is computed on
|
||||
// the full line amount at registration and cannot be deferred).
|
||||
if (body.items.some((item) => item.apply_slp && !isSlpPensionAccount(item.account_number))) {
|
||||
return errorResponseFromCode('SI_CREATE_SLP_INVALID_ACCOUNT', log, { requestId })
|
||||
}
|
||||
if (
|
||||
body.items.some(
|
||||
(item) =>
|
||||
item.apply_slp &&
|
||||
(item.accrual_period_start || item.accrual_period_end || item.accrual_balance_account),
|
||||
)
|
||||
) {
|
||||
return errorResponseFromCode('SI_CREATE_SLP_ACCRUAL', log, { requestId })
|
||||
}
|
||||
|
||||
const hasAccrualItems = body.items.some(
|
||||
(item) => item.accrual_period_start && item.accrual_period_end,
|
||||
)
|
||||
@@ -256,6 +274,9 @@ export const POST = withRouteContext(
|
||||
// Dimensions PR7: per-item bag, merged over default_dimensions on the
|
||||
// expense line at booking (supplier-invoice-entries.ts).
|
||||
dimensions: item.dimensions ?? {},
|
||||
// Särskild löneskatt (SLP): booking injects the self-balancing
|
||||
// 7533/2514 pair for this line. Guarded above (741x only, no accrual).
|
||||
apply_slp: item.apply_slp === true,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -112,6 +112,59 @@ describe('GET /api/transactions/[id]/match-supplier-invoice/preview: settlement
|
||||
expect(body.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(750)
|
||||
})
|
||||
|
||||
it('kontantmetod: the cash preview includes the SLP pair the POST will book for a flagged 741x line', async () => {
|
||||
// Regression: the cash branch previewed expense + VAT + bank only, while
|
||||
// createSupplierInvoiceCashEntry also books 7533 D / 2514 K for items
|
||||
// flagged apply_slp on a 741x pension account. The user approved four
|
||||
// lines and the POST committed six.
|
||||
enqueue({
|
||||
data: {
|
||||
id: TX_UUID,
|
||||
date: '2026-02-01',
|
||||
amount: -10000,
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
cash_account_id: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: SI_UUID,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
registration_journal_entry_id: null,
|
||||
items: [
|
||||
{
|
||||
description: 'Tjänstepension',
|
||||
line_total: 10000,
|
||||
vat_amount: 0,
|
||||
account_number: '7412',
|
||||
apply_slp: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null })
|
||||
|
||||
const res = await GET(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const { body } = await parseJsonResponse<{
|
||||
entry_type: string
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}>(res)
|
||||
|
||||
expect(body.entry_type).toBe('cash')
|
||||
// 10 000 × 0.2426 = 2 426: mirrors generateSlpLines in the engine.
|
||||
expect(body.lines.find((l) => l.account_number === '7533')?.debit_amount).toBe(2426)
|
||||
expect(body.lines.find((l) => l.account_number === '2514')?.credit_amount).toBe(2426)
|
||||
// The pair nets to zero: the bank credit stays at the invoice total.
|
||||
expect(body.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(10000)
|
||||
})
|
||||
|
||||
it('previews a credit to the linked cash account when it is not the primary 1930', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { generateSlpLines, isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { ORE_TOLERANCE } from '@/lib/money'
|
||||
@@ -241,6 +242,28 @@ export const GET = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror createSupplierInvoiceCashEntry's SLP pair: items flagged
|
||||
// apply_slp on a 741x pension account book 7533 D / 2514 K at the same
|
||||
// rate the expense lines above use. The pair nets to zero, so the bank
|
||||
// credit below is untouched; without it the dialog shows fewer lines
|
||||
// than the POST actually books.
|
||||
let slpBase = 0
|
||||
for (const it of items) {
|
||||
if (it.apply_slp !== true) continue
|
||||
if (!isSlpPensionAccount(it.account_number)) continue
|
||||
slpBase += resolveSekAmount(it.line_total, null, si.currency, cashRate)
|
||||
}
|
||||
if (slpBase > 0) {
|
||||
for (const l of generateSlpLines(slpBase)) {
|
||||
lines.push({
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
description: l.line_description ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
lines.push({
|
||||
account_number: paymentAccount,
|
||||
debit_amount: 0,
|
||||
|
||||
@@ -61,6 +61,12 @@ const SI_RESPONSE_COLUMNS =
|
||||
// decide whether a kontantmetoden credit note must reverse an entry the
|
||||
// payment already posted. `status` alone is too weak, it misses a
|
||||
// part-paid-but-booked original (rows predating the #1413 guard).
|
||||
//
|
||||
// Items keep `apply_slp` in the projection for the same reason as
|
||||
// default_dimensions above: createSupplierCreditNoteEntry reads the flag off
|
||||
// the ORIGINAL items to reverse the 7533/2514 SLP pair the registration
|
||||
// booked. Dropping it would leave the pension cost and the 2514 liability
|
||||
// standing forever after the credit.
|
||||
const SI_FULL_COLUMNS = `
|
||||
id, supplier_id, supplier_invoice_number, invoice_date, status,
|
||||
currency, exchange_rate,
|
||||
@@ -69,7 +75,7 @@ const SI_FULL_COLUMNS = `
|
||||
registration_journal_entry_id, payment_journal_entry_id, paid_at, paid_amount,
|
||||
is_credit_note, credited_invoice_id, arrival_number, default_dimensions,
|
||||
supplier:suppliers(id, name, supplier_type),
|
||||
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions)
|
||||
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions)
|
||||
`
|
||||
|
||||
const SupplierInvoiceCredited = z.object({
|
||||
@@ -184,6 +190,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
vat_rate: number
|
||||
vat_amount: number
|
||||
reverse_charge_rate: number | null
|
||||
apply_slp: boolean | null
|
||||
dimensions: Record<string, string> | null
|
||||
}>
|
||||
} & Record<string, unknown>
|
||||
@@ -332,6 +339,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// Preserve the self-assessed RC rate so the credit note reverses fiktiv
|
||||
// moms at the same rate the original was booked at.
|
||||
reverse_charge_rate: item.reverse_charge_rate,
|
||||
// Preserve the SLP flag for display parity with the web credit route;
|
||||
// the journal reversal reads the ORIGINAL items, so the 7533/2514 swap
|
||||
// is correct either way.
|
||||
apply_slp: item.apply_slp ?? false,
|
||||
// Same reasoning: the reversal must carry the exact per-item bag the
|
||||
// original booked with (dimensions PR7).
|
||||
dimensions: item.dimensions ?? {},
|
||||
|
||||
@@ -175,7 +175,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
subtotal, subtotal_sek, vat_amount, vat_amount_sek, total_sek, due_date, received_date,
|
||||
is_credit_note, credited_invoice_id, payment_journal_entry_id, default_dimensions,
|
||||
supplier:suppliers(id, name, supplier_type),
|
||||
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions)
|
||||
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions)
|
||||
`)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', invoiceId)
|
||||
|
||||
@@ -38,7 +38,7 @@ const SI_DETAIL_COLUMNS =
|
||||
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_at, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, transaction_id, document_id, notes, default_dimensions, reversed_at, created_at, updated_at'
|
||||
|
||||
const SI_ITEM_COLUMNS =
|
||||
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions'
|
||||
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions'
|
||||
|
||||
const SI_PAYMENT_COLUMNS =
|
||||
'id, payment_date, amount, currency, exchange_rate, exchange_rate_difference, journal_entry_id, transaction_id, notes, created_at'
|
||||
|
||||
@@ -84,11 +84,19 @@ interface TableResp {
|
||||
/** Payload handed to `.insert()`, recorded per table so writes can be asserted. */
|
||||
type InsertRecord = { table: string; payload: Record<string, unknown> }
|
||||
|
||||
/** Column string handed to `.select()`, recorded per table. The Proxy mock
|
||||
* returns fixture rows regardless of projection, so a column dropped from a
|
||||
* SELECT never fails these tests by itself: capturing the projection is the
|
||||
* only way to regression-test "this route must fetch column X". */
|
||||
type SelectRecord = { table: string; columns: string }
|
||||
|
||||
function makeFlexibleSupabase(
|
||||
byTable: Record<string, TableResp | TableResp[]>,
|
||||
// Opt-in sink for insert payloads: the Proxy chain is otherwise write-only,
|
||||
// and the route echoes back the fixture row rather than what it wrote.
|
||||
insertSink?: InsertRecord[],
|
||||
// Opt-in sink for select projections (see SelectRecord).
|
||||
selectSink?: SelectRecord[],
|
||||
) {
|
||||
// Per-table queue: TableResp[] consumes one entry per await, then sticks
|
||||
// on the last entry. Plain TableResp is treated as a constant.
|
||||
@@ -116,6 +124,9 @@ function makeFlexibleSupabase(
|
||||
) {
|
||||
insertSink.push({ table, payload: args[0] as Record<string, unknown> })
|
||||
}
|
||||
if (selectSink && prop === 'select' && typeof args[0] === 'string') {
|
||||
selectSink.push({ table, columns: args[0] })
|
||||
}
|
||||
return buildChain(table)
|
||||
}
|
||||
},
|
||||
@@ -286,6 +297,28 @@ describe('GET /api/v1/companies/:companyId/supplier-invoices/:id', () => {
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SI_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('?expand=items projects apply_slp so the flag is readable back', async () => {
|
||||
const selects: Array<{ table: string; columns: string }> = []
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase(
|
||||
{
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: { data: { ...SAMPLE_SI, items: [] }, error: null },
|
||||
},
|
||||
undefined,
|
||||
selects,
|
||||
),
|
||||
)
|
||||
const res = await getSI(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}?expand=items`),
|
||||
detailParams(COMPANY_ID, SI_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const siSelect = selects.find((s) => s.table === 'supplier_invoices')
|
||||
expect(siSelect).toBeDefined()
|
||||
expect(siSelect!.columns).toMatch(/items:supplier_invoice_items\([^)]*apply_slp/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/supplier-invoices', () => {
|
||||
@@ -1443,6 +1476,69 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid', ()
|
||||
expect(mockedPayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kontantmetod: fetches apply_slp on the items and hands them to the cash entry builder', async () => {
|
||||
// Regression: the items sub-select omitted apply_slp, so a kontantmetoden
|
||||
// payment via v1 booked the cash entry WITHOUT the 7533/2514 SLP pair
|
||||
// while the web mark-paid (select *) booked it. The projection is the
|
||||
// bug surface: the Proxy mock returns fixture rows regardless, so the
|
||||
// select string itself is asserted alongside the pass-through.
|
||||
const slpItem = {
|
||||
id: 'item-slp-1',
|
||||
sort_order: 0,
|
||||
description: 'Tjänstepension',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 1250,
|
||||
line_total: 1250,
|
||||
account_number: '7412',
|
||||
vat_code: null,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
reverse_charge_rate: null,
|
||||
apply_slp: true,
|
||||
dimensions: {},
|
||||
}
|
||||
const cashSI = {
|
||||
...SAMPLE_SI,
|
||||
status: 'approved',
|
||||
vat_amount: 0,
|
||||
subtotal: 1250,
|
||||
supplier: { id: SUPPLIER_ID, name: 'Avanza Pension', supplier_type: 'swedish_business' },
|
||||
items: [slpItem],
|
||||
}
|
||||
const selects: Array<{ table: string; columns: string }> = []
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase(
|
||||
{
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: { data: cashSI, error: null },
|
||||
company_settings: { data: { accounting_method: 'cash' }, error: null },
|
||||
fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null },
|
||||
supplier_invoice_payments: { data: null, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
},
|
||||
undefined,
|
||||
selects,
|
||||
),
|
||||
)
|
||||
const res = await markPaidSI(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, SI_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockedCash).toHaveBeenCalledTimes(1)
|
||||
// The projection must carry the flag: without it the engine can never
|
||||
// see apply_slp and silently skips the SLP pair.
|
||||
const siSelect = selects.find((s) => s.table === 'supplier_invoices')
|
||||
expect(siSelect).toBeDefined()
|
||||
expect(siSelect!.columns).toMatch(/items:supplier_invoice_items\([^)]*apply_slp/)
|
||||
// And the fetched items flow to createSupplierInvoiceCashEntry intact.
|
||||
const passedItems = mockedCash.mock.calls[0]?.[4] as Array<{ apply_slp?: boolean }>
|
||||
expect(passedItems[0]?.apply_slp).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects payment amount exceeding remaining_amount', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
@@ -1558,6 +1654,104 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/credit', () =>
|
||||
expect(body.data.original_id).toBe(SI_ID)
|
||||
})
|
||||
|
||||
it('reverses the SLP pair: fetches apply_slp, hands the flagged originals to the engine, copies the flag', async () => {
|
||||
// Regression (blocker): SI_FULL_COLUMNS omitted apply_slp, so the credit
|
||||
// path passed items without the flag to createSupplierCreditNoteEntry and
|
||||
// the 7533/2514 pair booked at registration was never reversed: the
|
||||
// pension cost and the 2514 liability stood forever, and the year-end
|
||||
// netting then under-provisioned.
|
||||
const slpSI = {
|
||||
...registeredSI,
|
||||
subtotal: 10000,
|
||||
vat_amount: 0,
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
items: [
|
||||
{
|
||||
sort_order: 0,
|
||||
description: 'Tjänstepension',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 10000,
|
||||
line_total: 10000,
|
||||
account_number: '7412',
|
||||
vat_code: null,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
reverse_charge_rate: null,
|
||||
apply_slp: true,
|
||||
dimensions: {},
|
||||
},
|
||||
],
|
||||
}
|
||||
const creditNoteRow = {
|
||||
...SAMPLE_SI,
|
||||
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
arrival_number: 43,
|
||||
supplier_invoice_number: 'KREDIT-2026-1234',
|
||||
is_credit_note: true,
|
||||
credited_invoice_id: SI_ID,
|
||||
}
|
||||
let siReadCount = 0
|
||||
let insertedItems: Array<{ apply_slp?: boolean }> = []
|
||||
const siSelects: string[] = []
|
||||
mockServiceClient.mockReturnValue({
|
||||
from: (table: string) => {
|
||||
return new Proxy({}, {
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
if (table === 'company_members') {
|
||||
resolve({ data: { company_id: COMPANY_ID, role: 'owner' }, error: null })
|
||||
} else if (table === 'supplier_invoices') {
|
||||
const n = siReadCount++
|
||||
if (n === 0) resolve({ data: slpSI, error: null })
|
||||
else if (n === 1) resolve({ data: creditNoteRow, error: null })
|
||||
else resolve({ data: { id: SI_ID, status: 'credited' }, error: null })
|
||||
} else if (table === 'company_settings') {
|
||||
resolve({ data: { accounting_method: 'accrual' }, error: null })
|
||||
} else if (table === 'fiscal_periods') {
|
||||
resolve({ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null })
|
||||
} else {
|
||||
resolve({ data: null, error: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (table === 'supplier_invoices' && prop === 'select' && typeof args[0] === 'string') {
|
||||
siSelects.push(args[0])
|
||||
}
|
||||
if (table === 'supplier_invoice_items' && prop === 'insert') {
|
||||
insertedItems = args[0] as Array<{ apply_slp?: boolean }>
|
||||
}
|
||||
return new Proxy({}, this!)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
rpc: vi.fn(() => Promise.resolve({ data: 43, error: null })),
|
||||
})
|
||||
|
||||
const res = await creditSI(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/credit`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, SI_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
// The original fetch must project apply_slp: the Proxy returns the
|
||||
// fixture regardless, so the select string is the regression surface.
|
||||
expect(siSelects[0]).toMatch(/items:supplier_invoice_items\([^)]*apply_slp/)
|
||||
// The ORIGINAL flagged items reach the engine so it can reverse the pair.
|
||||
expect(mockedCredit).toHaveBeenCalledTimes(1)
|
||||
const passedItems = mockedCredit.mock.calls[0]?.[4] as Array<{ apply_slp?: boolean }>
|
||||
expect(passedItems[0]?.apply_slp).toBe(true)
|
||||
// Parity with the web credit route: the flag is copied onto the new
|
||||
// credit-note items for display.
|
||||
expect(insertedItems[0]?.apply_slp).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 409 SI_CREDIT_ALREADY_CREDITED when status=credited', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
supplierInvoiceSekAmounts,
|
||||
} from '@/lib/currency/supplier-invoice-rate'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { eventBus } from '@/lib/events'
|
||||
@@ -281,7 +282,7 @@ const SI_RESPONSE_COLUMNS =
|
||||
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, notes, default_dimensions, created_at, updated_at'
|
||||
|
||||
const SI_ITEMS_RESPONSE_COLUMNS =
|
||||
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions'
|
||||
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions'
|
||||
|
||||
const SupplierInvoiceCreated = z.object({
|
||||
id: z.string().uuid(),
|
||||
@@ -321,6 +322,7 @@ registerEndpoint({
|
||||
'Foreign currency: omit exchange_rate and the server fetches Riksbanken\'s rate for invoice_date (ML 8 kap 21-23 §). If no rate can be resolved the create is refused with 400 SI_FX_RATE_MISSING rather than stored unconverted: pass exchange_rate explicitly to proceed. A SEK invoice needs no rate and gets total_sek = total.',
|
||||
'exchange_rate is SEK per 1 unit of the invoice currency and must satisfy 0 < rate < 100000, the same bounds the supplier_invoices CHECK enforces. Out-of-range values return 400 VALIDATION_ERROR; passing an invoice total where a rate belongs is the usual cause.',
|
||||
'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). The registration JE lines are tagged accordingly. When the company has the dimension registry enabled, unknown or archived codes are rejected with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.',
|
||||
'Tjänstepension invoices (Avanza etc.): set items[].apply_slp=true on the 741x premium line and the registration JE also books särskild löneskatt (debit 7533 / credit 2514 at 24.26% of the line amount) beyond the payable: 2440 stays at the invoice total. apply_slp on a non-741x account returns 400 SI_CREATE_SLP_INVALID_ACCOUNT.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
@@ -367,6 +369,7 @@ interface ComputedItem {
|
||||
vat_rate: number
|
||||
vat_amount: number
|
||||
reverse_charge_rate: number | null
|
||||
apply_slp: boolean
|
||||
dimensions: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -412,6 +415,10 @@ function computeItemsAndTotals(input: z.infer<typeof CreateSupplierInvoiceSchema
|
||||
// line vat_rate is 0 (validated below); the engine self-assesses at this
|
||||
// rate, defaulting to 25% huvudregeln when null.
|
||||
reverse_charge_rate: item.reverse_charge_rate ?? null,
|
||||
// Särskild löneskatt (SLP): booking injects the self-balancing
|
||||
// 7533/2514 pair for this line. Validated in the POST handler
|
||||
// (741x accounts only, never together with periodisering).
|
||||
apply_slp: item.apply_slp === true,
|
||||
// Dimensions PR7: per-item bag, merged over the invoice's
|
||||
// default_dimensions on the expense line at booking.
|
||||
dimensions: item.dimensions ?? {},
|
||||
@@ -463,6 +470,26 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// Särskild löneskatt (SLP): the 7533/2514 pair is only lawful on 741x
|
||||
// pension premiums and cannot be combined with periodisering on the same
|
||||
// item. Same guards as POST /api/supplier-invoices.
|
||||
if (body.items.some((it) => it.apply_slp && !isSlpPensionAccount(it.account_number))) {
|
||||
return v1ErrorResponseFromCode('SI_CREATE_SLP_INVALID_ACCOUNT', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
if (
|
||||
body.items.some(
|
||||
(it) =>
|
||||
it.apply_slp &&
|
||||
(it.accrual_period_start || it.accrual_period_end || it.accrual_balance_account),
|
||||
)
|
||||
) {
|
||||
return v1ErrorResponseFromCode('SI_CREATE_SLP_ACCRUAL', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// Supplier lookup. Scoped to company; deny soft-archived.
|
||||
const { data: supplier, error: supplierErr } = await ctx.supabase
|
||||
.from('suppliers')
|
||||
|
||||
Reference in New Issue
Block a user